From 8b5b6d0821ac6f857a99b1a14d59ffd1bcea9898 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Oct 2023 15:34:01 -0500 Subject: [PATCH] feat(web): manual stacking asset (#4650) Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- cli/src/api/open-api/api.ts | 46 +++- mobile/openapi/doc/AssetApi.md | Bin 66403 -> 66617 bytes mobile/openapi/lib/api/asset_api.dart | Bin 58283 -> 58765 bytes mobile/openapi/test/asset_api_test.dart | Bin 5854 -> 5890 bytes server/immich-openapi-specs.json | 16 ++ server/src/domain/asset/asset.service.ts | 2 +- .../src/domain/asset/dto/time-bucket.dto.ts | 5 + .../domain/repositories/asset.repository.ts | 1 + .../infra/repositories/asset.repository.ts | 17 +- web/src/api/open-api/api.ts | 46 +++- .../asset-viewer/asset-viewer-nav-bar.svelte | 9 +- .../asset-viewer/asset-viewer.svelte | 198 +++++++++++++++--- .../asset-viewer/detail-panel.svelte | 7 + .../asset-viewer/photo-viewer.svelte | 5 +- .../assets/thumbnail/thumbnail.svelte | 40 +++- .../photos-page/actions/stack-action.svelte | 57 +++++ .../photos-page/asset-date-group.svelte | 2 + .../components/photos-page/asset-grid.svelte | 4 + .../asset-select-control-bar.svelte | 1 + web/src/lib/stores/assets.store.ts | 3 +- web/src/lib/stores/stacked-asset.store.ts | 4 + web/src/routes/(user)/photos/+page.svelte | 14 +- 22 files changed, 407 insertions(+), 70 deletions(-) create mode 100644 web/src/lib/components/photos-page/actions/stack-action.svelte create mode 100644 web/src/lib/stores/stacked-asset.store.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 809f3f0079..e050ebc28d 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -6323,11 +6323,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getByTimeBucket', 'size', size) // verify required parameter 'timeBucket' is not null or undefined @@ -6381,6 +6382,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isTrashed'] = isTrashed; } + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + if (timeBucket !== undefined) { localVarQueryParameter['timeBucket'] = timeBucket; } @@ -6691,11 +6696,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getTimeBuckets', 'size', size) const localVarPath = `/asset/time-buckets`; @@ -6747,6 +6753,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isTrashed'] = isTrashed; } + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -7485,12 +7495,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); + async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7565,12 +7576,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); + async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7815,7 +7827,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -7876,7 +7888,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * Get all asset of a device that are in the database, ID only. @@ -8251,6 +8263,13 @@ export interface AssetApiGetByTimeBucketRequest { */ readonly isTrashed?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetByTimeBucket + */ + readonly withStacked?: boolean + /** * * @type {string} @@ -8405,6 +8424,13 @@ export interface AssetApiGetTimeBucketsRequest { */ readonly isTrashed?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetTimeBuckets + */ + readonly withStacked?: boolean + /** * * @type {string} @@ -8820,7 +8846,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8895,7 +8921,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index f80bf42dfa3fdf89fd3b84e0a944174ffcdf0099..d606e540f7eb119376c5bfba7e34efe05832e8b2 100644 GIT binary patch delta 157 zcmaFd#Q3`S{#f}Gg&JuYIE?Cos39AK$9lN<~VHjTqYF@0NoNlC;$Ke delta 75 zcmdnl!Sc9`Wy96v&5x2du})gzHJLdpcCv9(*yh7ocNixpBwB11o3op7^S-=E`amUK aESr}vGGUywWENP>-z7UCYNVFS#{vLaH6uO% diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 5ce3fad90bc23bd1503ee00af2416a6831fcad12..866d11a37378b22f6c981cacbb0c2da26e40ade0 100644 GIT binary patch delta 408 zcmZ2|oVoWj^M+{wlO4{nag}G5WCWKaCTFKke&{bX*?+6V=PUbL7LE$)_T=0cFf4 ypNw3wxl!Q-r2pk3@Gi^X3nIejq)EfT9->YMZ8V2LJ$1S~Q&i diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index e7b2978eb459ba88db7ac3242738795fbab628da..c2a07323c51b61947d8f17cbb08e5366fc8b68a0 100644 GIT binary patch delta 51 ucmcbo+oZR_kBcuUKR-vIJhLPtxFj(-domxt*5(qfc`Rt+oB8-Jumb?!uo5r; delta 25 dcmZqDyQjOsk8AQG7Uj*&Tnkyij7a{K>;Qix2`m5r diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index d934d50b3c..e19d62d504 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1841,6 +1841,14 @@ "type": "boolean" } }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "timeBucket", "required": true, @@ -1961,6 +1969,14 @@ "type": "boolean" } }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "key", "required": false, diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index e3736984cc..9b6322dacd 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -201,7 +201,7 @@ export class AssetService { await this.timeBucketChecks(authUser, dto); const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); if (authUser.isShowMetadata) { - return assets.map((asset) => mapAsset(asset)); + return assets.map((asset) => mapAsset(asset, { withStack: true })); } else { return assets.map((asset) => mapAsset(asset, { stripMetadata: true })); } diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index 7c6b3253b2..db2f1ecd0b 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -33,6 +33,11 @@ export class TimeBucketDto { @IsBoolean() @Transform(toBoolean) isTrashed?: boolean; + + @Optional() + @IsBoolean() + @Transform(toBoolean) + withStacked?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 5266c98ae7..086b9ba1ad 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -65,6 +65,7 @@ export interface TimeBucketOptions { albumId?: string; personId?: string; userId?: string; + withStacked?: boolean; } export interface TimeBucketItem { diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index a740cf583a..4947c0d76d 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -30,7 +30,9 @@ const truncateMap: Record = { }; const dateTrunc = (options: TimeBucketOptions) => - `(date_trunc('${truncateMap[options.size]}', ("localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; + `(date_trunc('${ + truncateMap[options.size] + }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; @Injectable() export class AssetRepository implements IAssetRepository { @@ -505,13 +507,14 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: TimeBucketOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userId } = options; + const { isArchived, isFavorite, isTrashed, albumId, personId, userId, withStacked } = options; let builder = this.repository .createQueryBuilder('asset') .where('asset.isVisible = true') .andWhere('asset.fileCreatedAt < NOW()') - .leftJoinAndSelect('asset.exifInfo', 'exifInfo'); + .leftJoinAndSelect('asset.exifInfo', 'exifInfo') + .leftJoinAndSelect('asset.stack', 'stack'); if (albumId) { builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); @@ -540,11 +543,9 @@ export class AssetRepository implements IAssetRepository { .andWhere('person.id = :personId', { personId }); } - // Hide stack children only in main timeline - // Uncomment after adding support for stacked assets in web client - // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) { - // builder = builder.andWhere('asset.stackParent IS NULL'); - // } + if (withStacked) { + builder = builder.andWhere('asset.stackParentId IS NULL'); + } return builder; } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 809f3f0079..e050ebc28d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6323,11 +6323,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getByTimeBucket', 'size', size) // verify required parameter 'timeBucket' is not null or undefined @@ -6381,6 +6382,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isTrashed'] = isTrashed; } + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + if (timeBucket !== undefined) { localVarQueryParameter['timeBucket'] = timeBucket; } @@ -6691,11 +6696,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { + getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'size' is not null or undefined assertParamExists('getTimeBuckets', 'size', size) const localVarPath = `/asset/time-buckets`; @@ -6747,6 +6753,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['isTrashed'] = isTrashed; } + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -7485,12 +7495,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); + async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7565,12 +7576,13 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {boolean} [isArchived] * @param {boolean} [isFavorite] * @param {boolean} [isTrashed] + * @param {boolean} [withStacked] * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key, options); + async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -7815,7 +7827,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -7876,7 +7888,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(axios, basePath)); + return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * Get all asset of a device that are in the database, ID only. @@ -8251,6 +8263,13 @@ export interface AssetApiGetByTimeBucketRequest { */ readonly isTrashed?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetByTimeBucket + */ + readonly withStacked?: boolean + /** * * @type {string} @@ -8405,6 +8424,13 @@ export interface AssetApiGetTimeBucketsRequest { */ readonly isTrashed?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiGetTimeBuckets + */ + readonly withStacked?: boolean + /** * * @type {string} @@ -8820,7 +8846,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getByTimeBucket(requestParameters: AssetApiGetByTimeBucketRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getByTimeBucket(requestParameters.size, requestParameters.timeBucket, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -8895,7 +8921,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public getTimeBuckets(requestParameters: AssetApiGetTimeBucketsRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.isTrashed, requestParameters.withStacked, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 8da63e03ba..6fd25887ef 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -32,10 +32,11 @@ export let showDownloadButton: boolean; export let showDetailButton: boolean; export let showSlideshow = false; + export let hasStackChildern = false; $: isOwner = asset.ownerId === $page.data.user?.id; - type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow'; + type MenuItemEvent = 'addToAlbum' | 'addToSharedAlbum' | 'asProfileImage' | 'runJob' | 'playSlideShow' | 'unstack'; const dispatch = createEventDispatcher<{ goBack: void; @@ -51,6 +52,7 @@ asProfileImage: void; runJob: AssetJobName; playSlideShow: void; + unstack: void; }>(); let contextMenuPosition = { x: 0, y: 0 }; @@ -173,6 +175,11 @@ text={asset.isArchived ? 'Unarchive' : 'Archive'} /> onMenuClick('asProfileImage')} text="As profile picture" /> + + {#if hasStackChildern} + onMenuClick('unstack')} text="Un-Stack" /> + {/if} + onJobClick(AssetJobName.RefreshMetadata)} text={api.getAssetJobName(AssetJobName.RefreshMetadata)} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 9e8b0eb114..73bdc77b16 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -25,6 +25,8 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiImageBrokenVariant, mdiPause, mdiPlay } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; + import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -32,6 +34,7 @@ export let sharedLink: SharedLinkResponseDto | undefined = undefined; $: isTrashEnabled = $featureFlags.trash; export let force = false; + export let withStacked = false; const dispatch = createEventDispatcher<{ archived: AssetResponseDto; @@ -41,6 +44,7 @@ close: void; next: void; previous: void; + unstack: void; }>(); let appearsInAlbums: AlbumResponseDto[] = []; @@ -52,6 +56,21 @@ let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let shouldShowDetailButton = asset.hasMetadata; let canCopyImagesToClipboard: boolean; + let previewStackedAsset: AssetResponseDto | undefined; + $: displayedAsset = previewStackedAsset || asset; + + $: { + if (asset.stackCount && asset.stack) { + $stackAssetsStore = asset.stack; + $stackAssetsStore = [...$stackAssetsStore, asset].sort( + (a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(), + ); + } + + if (!$stackAssetsStore.map((a) => a.id).includes(asset.id)) { + $stackAssetsStore = []; + } + } const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo); @@ -66,6 +85,15 @@ // TODO: Move to regular import once the package correctly supports ESM. const module = await import('copy-image-clipboard'); canCopyImagesToClipboard = module.canCopyImagesToClipboard(); + + if (asset.stackCount && asset.stack) { + $stackAssetsStore = asset.stack; + $stackAssetsStore = [...$stackAssetsStore, asset].sort( + (a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(), + ); + } else { + $stackAssetsStore = []; + } }); onDestroy(() => { @@ -351,6 +379,35 @@ progressBar.restart(false); } }; + + const handleStackedAssetMouseEvent = (e: CustomEvent<{ isMouseOver: boolean }>, asset: AssetResponseDto) => { + const { isMouseOver } = e.detail; + + if (isMouseOver) { + previewStackedAsset = asset; + } else { + previewStackedAsset = undefined; + } + }; + + const handleUnstack = async () => { + try { + const ids = $stackAssetsStore.map(({ id }) => id); + await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } }); + for (const child of $stackAssetsStore) { + child.stackParentId = null; + assetStore?.addAsset(child); + } + asset.stackCount = 0; + asset.stack = []; + assetStore?.updateAsset(asset); + + dispatch('unstack'); + notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); + } catch (error) { + await handleError(error, `Unable to unstack`); + } + };
0} on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} on:download={() => downloadFile(asset)} @@ -403,6 +461,7 @@ on:asProfileImage={() => (isShowProfileImageCrop = true)} on:runJob={({ detail: job }) => handleRunJob(job)} on:playSlideShow={handlePlaySlideshow} + on:unstack={handleUnstack} /> {/if} @@ -413,41 +472,95 @@ {/if} +
- {#key asset.id} - {#if !asset.resized} -
-
- -
-
- {:else if asset.type === AssetTypeEnum.Image} - {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} - (shouldPlayMotionPhoto = false)} - /> - {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath - .toLowerCase() - .endsWith('.insp'))} - + + {#if displayedAsset} + {#key displayedAsset.id} + {#if displayedAsset.type === AssetTypeEnum.Image} + {:else} - + {/if} - {:else} - - {/if} - {/key} + {/key} + {:else} + {#key asset.id} + {#if !asset.resized} +
+
+ +
+
+ {:else if asset.type === AssetTypeEnum.Image} + {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} + (shouldPlayMotionPhoto = false)} + /> + {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath + .toLowerCase() + .endsWith('.insp'))} + + {:else} + + {/if} + {:else} + + {/if} + {/key} + {/if} + + {#if $stackAssetsStore.length > 0 && withStacked} +
+
+ {#each $stackAssetsStore as stackedAsset (stackedAsset.id)} +
+ (asset = stackedAsset)} + on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)} + readonly + thumbnailSize={stackedAsset.id == asset.id ? 65 : 60} + showStackedIcon={false} + /> + + {#if stackedAsset.id == asset.id} +
+
+
+ {/if} +
+ {/each} +
+
+ {/if}
+ + {#if !isSlideshowMode && showNavigation}
@@ -458,7 +571,7 @@
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index f99164285b..56efb4c642 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -40,6 +40,13 @@ } })(); + $: { + if (!asset.exifInfo) { + api.assetApi.getAssetById({ id: asset.id }).then((res) => { + asset.exifInfo = res.data?.exifInfo; + }); + } + } $: lat = latlng ? latlng[0] : undefined; $: lng = latlng ? latlng[1] : undefined; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 9049d6cc35..04c8bdf590 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -10,6 +10,7 @@ export let asset: AssetResponseDto; export let element: HTMLDivElement | undefined = undefined; + export let haveFadeTransition = true; let imgElement: HTMLDivElement; let assetData: string; @@ -116,7 +117,7 @@
{#await loadAssetData({ loadOriginal: false })} @@ -124,7 +125,7 @@ {:then}
{asset.id}(); export let asset: AssetResponseDto; export let groupIndex = 0; @@ -31,6 +36,10 @@ export let disabled = false; export let readonly = false; export let showArchiveIcon = false; + export let showStackedIcon = true; + + let className = ''; + export { className as class }; let mouseOver = false; @@ -66,6 +75,14 @@ dispatch('select', { asset }); } }; + + const onMouseEnter = () => { + mouseOver = true; + }; + + const onMouseLeave = () => { + mouseOver = false; + }; @@ -78,13 +95,13 @@ : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}" class:cursor-not-allowed={disabled} class:hover:cursor-pointer={!disabled} - on:mouseenter={() => (mouseOver = true)} - on:mouseleave={() => (mouseOver = false)} + on:mouseenter={() => onMouseEnter()} + on:mouseleave={() => onMouseLeave()} on:click={thumbnailClickedHandler} on:keydown={thumbnailKeyDownHandler} > {#if intersecting} -
+
{#if !readonly && (mouseOver || selected || selectionCandidate)}