diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 8c94e09bf5..0396250c7d 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -44,7 +44,15 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + /// + /// * [double] x1: + /// + /// * [double] x2: + /// + /// * [double] y1: + /// + /// * [double] y2: + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async { // ignore: prefer_const_declarations final path = r'/timeline/bucket'; @@ -90,6 +98,18 @@ class TimelineApi { if (withStacked != null) { queryParams.addAll(_queryParams('', 'withStacked', withStacked)); } + if (x1 != null) { + queryParams.addAll(_queryParams('', 'x1', x1)); + } + if (x2 != null) { + queryParams.addAll(_queryParams('', 'x2', x2)); + } + if (y1 != null) { + queryParams.addAll(_queryParams('', 'y1', y1)); + } + if (y2 != null) { + queryParams.addAll(_queryParams('', 'y2', y2)); + } const contentTypes = []; @@ -132,8 +152,16 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + /// + /// * [double] x1: + /// + /// * [double] x2: + /// + /// * [double] y1: + /// + /// * [double] y2: + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, x1: x1, x2: x2, y1: y1, y2: y2, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -176,7 +204,15 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + /// + /// * [double] x1: + /// + /// * [double] x2: + /// + /// * [double] y1: + /// + /// * [double] y2: + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async { // ignore: prefer_const_declarations final path = r'/timeline/buckets'; @@ -221,6 +257,18 @@ class TimelineApi { if (withStacked != null) { queryParams.addAll(_queryParams('', 'withStacked', withStacked)); } + if (x1 != null) { + queryParams.addAll(_queryParams('', 'x1', x1)); + } + if (x2 != null) { + queryParams.addAll(_queryParams('', 'x2', x2)); + } + if (y1 != null) { + queryParams.addAll(_queryParams('', 'y1', y1)); + } + if (y2 != null) { + queryParams.addAll(_queryParams('', 'y2', y2)); + } const contentTypes = []; @@ -261,8 +309,16 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + /// + /// * [double] x1: + /// + /// * [double] x2: + /// + /// * [double] y1: + /// + /// * [double] y2: + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, double? x1, double? x2, double? y1, double? y2, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, x1: x1, x2: x2, y1: y1, y2: y2, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b80bb52a11..278f08c485 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6632,6 +6632,50 @@ "schema": { "type": "boolean" } + }, + { + "name": "x1", + "required": false, + "in": "query", + "schema": { + "minimum": -180, + "maximum": 180, + "format": "double", + "type": "number" + } + }, + { + "name": "x2", + "required": false, + "in": "query", + "schema": { + "minimum": -180, + "maximum": 180, + "format": "double", + "type": "number" + } + }, + { + "name": "y1", + "required": false, + "in": "query", + "schema": { + "minimum": -90, + "maximum": 90, + "format": "double", + "type": "number" + } + }, + { + "name": "y2", + "required": false, + "in": "query", + "schema": { + "minimum": -90, + "maximum": 90, + "format": "double", + "type": "number" + } } ], "responses": { @@ -6768,6 +6812,50 @@ "schema": { "type": "boolean" } + }, + { + "name": "x1", + "required": false, + "in": "query", + "schema": { + "minimum": -180, + "maximum": 180, + "format": "double", + "type": "number" + } + }, + { + "name": "x2", + "required": false, + "in": "query", + "schema": { + "minimum": -180, + "maximum": 180, + "format": "double", + "type": "number" + } + }, + { + "name": "y1", + "required": false, + "in": "query", + "schema": { + "minimum": -90, + "maximum": 90, + "format": "double", + "type": "number" + } + }, + { + "name": "y2", + "required": false, + "in": "query", + "schema": { + "minimum": -90, + "maximum": 90, + "format": "double", + "type": "number" + } } ], "responses": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7cf4d48eda..e7eb876d7d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2986,7 +2986,7 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked, x1, x2, y1, y2 }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -3000,6 +3000,10 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, userId?: string; withPartners?: boolean; withStacked?: boolean; + x1?: number; + x2?: number; + y1?: number; + y2?: number; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3017,12 +3021,16 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, timeBucket, userId, withPartners, - withStacked + withStacked, + x1, + x2, + y1, + y2 }))}`, { ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked, x1, x2, y1, y2 }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -3035,6 +3043,10 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key userId?: string; withPartners?: boolean; withStacked?: boolean; + x1?: number; + x2?: number; + y1?: number; + y2?: number; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3051,7 +3063,11 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key tagId, userId, withPartners, - withStacked + withStacked, + x1, + x2, + y1, + y2 }))}`, { ...opts })); diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index dd7a01df35..fcc1c32fbe 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; import { AssetOrder } from 'src/enum'; import { TimeBucketSize } from 'src/interfaces/asset.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; @@ -41,6 +42,38 @@ export class TimeBucketDto { @Optional() @ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' }) order?: AssetOrder; + + @Optional() + @IsNumber() + @Min(-180) + @Max(180) + @Type(() => Number) + @ApiProperty({ type: 'number', format: 'double' }) + x1?: number; + + @Optional() + @IsNumber() + @Min(-90) + @Max(90) + @Type(() => Number) + @ApiProperty({ type: 'number', format: 'double' }) + y1?: number; + + @Optional() + @IsNumber() + @Min(-180) + @Max(180) + @Type(() => Number) + @ApiProperty({ type: 'number', format: 'double' }) + x2?: number; + + @Optional() + @IsNumber() + @Min(-90) + @Max(90) + @Type(() => Number) + @ApiProperty({ type: 'number', format: 'double' }) + y2?: number; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 9f7213de82..c8671e96d8 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -57,6 +57,10 @@ export interface AssetBuilderOptions { withStacked?: boolean; exifInfo?: boolean; assetType?: AssetType; + x1?: number; + x2?: number; + y1?: number; + y2?: number; } export interface TimeBucketOptions extends AssetBuilderOptions { diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index da5ec1d4d1..befcff9316 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -694,7 +694,13 @@ FROM LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" AND ("stackedAssets"."deletedAt" IS NULL) WHERE - ("asset"."isVisible" = true) + ( + "asset"."isVisible" = true + AND "exifInfo"."longitude" > $1 + AND "exifInfo"."longitude" < $2 + AND "exifInfo"."latitude" > $3 + AND "exifInfo"."latitude" < $4 + ) AND ("asset"."deletedAt" IS NULL) GROUP BY ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 059a05f9e7..329e504faa 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -633,7 +633,7 @@ export class AssetRepository implements IAssetRepository { return builder.orderBy('RANDOM()').limit(count).getMany(); } - @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) + @GenerateSql({ params: [{ size: TimeBucketSize.MONTH, x1: 1, x2: 2, y1: 2, y2: 1 }] }) getTimeBuckets(options: TimeBucketOptions): Promise { const truncated = dateTrunc(options); return this.getBuilder(options) @@ -789,6 +789,34 @@ export class AssetRepository implements IAssetRepository { ); } + if (options.x1 && options.x2 && options.y1 && options.y2) { + /** + /* the API already makes sure that -180 < x1, x2 < 180 + /* the first case is when you search an asset with the International Date Line (x1 is on the west side, x2 on the east) + */ + if (options.x1 > options.x2) { + builder.andWhere( + '(exifInfo.longitude > :x1 OR exifInfo.longitude < :x2) AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1', + { + x1: options.x1, + x2: options.x2, + y1: options.y1, + y2: options.y2, + }, + ); + } else { + builder.andWhere( + 'exifInfo.longitude > :x1 AND exifInfo.longitude < :x2 AND exifInfo.latitude > :y2 AND exifInfo.latitude < :y1', + { + x1: options.x1, + x2: options.x2, + y1: options.y1, + y2: options.y2, + }, + ); + } + } + return builder; } diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 205eb26699..c4bfefacbb 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -197,8 +197,8 @@ ($boundingBoxesArray = people[index].faces)} on:blur={() => ($boundingBoxesArray = [])} on:mouseover={() => ($boundingBoxesArray = people[index].faces)} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 21f48e42eb..d90264e600 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -42,7 +42,7 @@ use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }} > (showVerticalDots = true)} > diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 5bca13b060..60883f973a 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -14,6 +14,7 @@ export let description: string | undefined = undefined; export let scrollbar = true; export let admin = false; + export let showCustomSidebar: boolean = false; $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; @@ -34,7 +35,9 @@ {#if admin} {:else} - + + + {/if} diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index d769d8a559..f355c9a8cc 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -4,17 +4,16 @@ {#await style then style} @@ -140,6 +263,7 @@ on:load={(event) => event.detail.setMaxZoom(18)} on:load={(event) => event.detail.on('click', handleMapClick)} bind:map + on:moveend={changeBounds} > @@ -222,6 +346,51 @@ {/if} + + {#if showAssetGrid && !$mapSettings.withSharedAlbums && !$mapSettings.includeArchived} + + {/if}