Browse Source

feat(web): manual stacking asset (#4650)


Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Alex 1 year ago
parent
commit
8b5b6d0821

+ 36 - 10
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<RequestArgs> => {
+        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<RequestArgs> => {
             // 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<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<Array<AssetResponseDto>>> {
-            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<Array<AssetResponseDto>>> {
+            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<Array<TimeBucketResponseDto>>> {
-            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<Array<TimeBucketResponseDto>>> {
+            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<Array<AssetResponseDto>> {
-            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<Array<TimeBucketResponseDto>> {
-            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));
     }
 
     /**

+ 8 - 4
mobile/openapi/doc/AssetApi.md

@@ -669,7 +669,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getByTimeBucket**
-> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key)
+> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key)
 
 
 
@@ -700,10 +700,11 @@ final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final isTrashed = true; // bool | 
+final withStacked = true; // bool | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, key);
+    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getByTimeBucket: $e\n');
@@ -722,6 +723,7 @@ Name | Type | Description  | Notes
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isTrashed** | **bool**|  | [optional] 
+ **withStacked** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 ### Return type
@@ -1072,7 +1074,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getTimeBuckets**
-> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key)
+> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key)
 
 
 
@@ -1102,10 +1104,11 @@ final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final isTrashed = true; // bool | 
+final withStacked = true; // bool | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, key);
+    final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, isTrashed, withStacked, key);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getTimeBuckets: $e\n');
@@ -1123,6 +1126,7 @@ Name | Type | Description  | Notes
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isTrashed** | **bool**|  | [optional] 
+ **withStacked** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 ### Return type

+ 20 - 6
mobile/openapi/lib/api/asset_api.dart

@@ -652,8 +652,10 @@ class AssetApi {
   ///
   /// * [bool] isTrashed:
   ///
+  /// * [bool] withStacked:
+  ///
   /// * [String] key:
-  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
+  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/time-bucket';
 
@@ -682,6 +684,9 @@ class AssetApi {
     }
     if (isTrashed != null) {
       queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
+    }
+    if (withStacked != null) {
+      queryParams.addAll(_queryParams('', 'withStacked', withStacked));
     }
       queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
     if (key != null) {
@@ -720,9 +725,11 @@ class AssetApi {
   ///
   /// * [bool] isTrashed:
   ///
+  /// * [bool] withStacked:
+  ///
   /// * [String] key:
-  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
-    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, );
+  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async {
+    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, withStacked: withStacked, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -1085,8 +1092,10 @@ class AssetApi {
   ///
   /// * [bool] isTrashed:
   ///
+  /// * [bool] withStacked:
+  ///
   /// * [String] key:
-  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
+  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/time-buckets';
 
@@ -1116,6 +1125,9 @@ class AssetApi {
     if (isTrashed != null) {
       queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
     }
+    if (withStacked != null) {
+      queryParams.addAll(_queryParams('', 'withStacked', withStacked));
+    }
     if (key != null) {
       queryParams.addAll(_queryParams('', 'key', key));
     }
@@ -1150,9 +1162,11 @@ class AssetApi {
   ///
   /// * [bool] isTrashed:
   ///
+  /// * [bool] withStacked:
+  ///
   /// * [String] key:
-  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, }) async {
-    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, );
+  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, bool? isTrashed, bool? withStacked, String? key, }) async {
+    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, withStacked: withStacked, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 2 - 2
mobile/openapi/test/asset_api_test.dart

@@ -80,7 +80,7 @@ void main() {
       // TODO
     });
 
-    //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async
+    //Future<List<AssetResponseDto>> getByTimeBucket(TimeBucketSize size, String timeBucket, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, bool withStacked, String key }) async
     test('test getByTimeBucket', () async {
       // TODO
     });
@@ -115,7 +115,7 @@ void main() {
       // TODO
     });
 
-    //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, String key }) async
+    //Future<List<TimeBucketResponseDto>> getTimeBuckets(TimeBucketSize size, { String userId, String albumId, String personId, bool isArchived, bool isFavorite, bool isTrashed, bool withStacked, String key }) async
     test('test getTimeBuckets', () async {
       // TODO
     });

+ 16 - 0
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,

+ 1 - 1
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 }));
     }

+ 5 - 0
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 {

+ 1 - 0
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 {

+ 9 - 8
server/src/infra/repositories/asset.repository.ts

@@ -30,7 +30,9 @@ const truncateMap: Record<TimeBucketSize, string> = {
 };
 
 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;
   }

+ 36 - 10
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<RequestArgs> => {
+        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<RequestArgs> => {
             // 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<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, isTrashed?: boolean, withStacked?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<Array<AssetResponseDto>>> {
-            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<Array<AssetResponseDto>>> {
+            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<Array<TimeBucketResponseDto>>> {
-            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<Array<TimeBucketResponseDto>>> {
+            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<Array<AssetResponseDto>> {
-            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<Array<TimeBucketResponseDto>> {
-            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));
     }
 
     /**

+ 8 - 1
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'}
               />
               <MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
+
+              {#if hasStackChildern}
+                <MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
+              {/if}
+
               <MenuOption
                 on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
                 text={api.getAssetJobName(AssetJobName.RefreshMetadata)}

+ 164 - 28
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`);
+    }
+  };
 </script>
 
 <section
@@ -390,6 +447,7 @@
         showDownloadButton={shouldShowDownloadButton}
         showDetailButton={shouldShowDetailButton}
         showSlideshow={!!assetStore}
+        hasStackChildern={$stackAssetsStore.length > 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}
   </div>
@@ -413,41 +472,95 @@
     </div>
   {/if}
 
+  <!-- Asset Viewer -->
   <div class="col-span-4 col-start-1 row-span-full row-start-1">
-    {#key asset.id}
-      {#if !asset.resized}
-        <div class="flex h-full w-full justify-center">
-          <div
-            class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
-          >
-            <Icon path={mdiImageBrokenVariant} size="25%" />
-          </div>
-        </div>
-      {:else if asset.type === AssetTypeEnum.Image}
-        {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
+    <!-- Condition to show preview of stacked asset on hovered -->
+    {#if displayedAsset}
+      {#key displayedAsset.id}
+        {#if displayedAsset.type === AssetTypeEnum.Image}
+          <PhotoViewer asset={displayedAsset} on:close={closeViewer} haveFadeTransition={false} />
+        {:else}
           <VideoViewer
-            assetId={asset.livePhotoVideoId}
+            assetId={displayedAsset.id}
             on:close={closeViewer}
-            on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
+            on:onVideoEnded={handleVideoEnded}
+            on:onVideoStarted={handleVideoStarted}
           />
-        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
-              .toLowerCase()
-              .endsWith('.insp'))}
-          <PanoramaViewer {asset} />
+        {/if}
+      {/key}
+    {:else}
+      {#key asset.id}
+        {#if !asset.resized}
+          <div class="flex h-full w-full justify-center">
+            <div
+              class="px-auto flex aspect-square h-full items-center justify-center bg-gray-100 dark:bg-immich-dark-gray"
+            >
+              <Icon path={mdiImageBrokenVariant} size="25%" />
+            </div>
+          </div>
+        {:else if asset.type === AssetTypeEnum.Image}
+          {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
+            <VideoViewer
+              assetId={asset.livePhotoVideoId}
+              on:close={closeViewer}
+              on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
+            />
+          {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
+                .toLowerCase()
+                .endsWith('.insp'))}
+            <PanoramaViewer {asset} />
+          {:else}
+            <PhotoViewer {asset} on:close={closeViewer} />
+          {/if}
         {:else}
-          <PhotoViewer {asset} on:close={closeViewer} />
+          <VideoViewer
+            assetId={asset.id}
+            on:close={closeViewer}
+            on:onVideoEnded={handleVideoEnded}
+            on:onVideoStarted={handleVideoStarted}
+          />
         {/if}
-      {:else}
-        <VideoViewer
-          assetId={asset.id}
-          on:close={closeViewer}
-          on:onVideoEnded={handleVideoEnded}
-          on:onVideoStarted={handleVideoStarted}
-        />
-      {/if}
-    {/key}
+      {/key}
+    {/if}
+
+    {#if $stackAssetsStore.length > 0 && withStacked}
+      <div
+        id="stack-slideshow"
+        class="z-[1005] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 mb-1 overflow-x-auto horizontal-scrollbar"
+      >
+        <div class="relative whitespace-nowrap transition-all">
+          {#each $stackAssetsStore as stackedAsset (stackedAsset.id)}
+            <div
+              class="{stackedAsset.id == asset.id
+                ? '-translate-y-[1px]'
+                : '-translate-y-0'} inline-block px-1 transition-transform"
+            >
+              <Thumbnail
+                class="{stackedAsset.id == asset.id
+                  ? 'bg-transparent border-2 border-white'
+                  : 'bg-gray-700/40'} inline-block hover:bg-transparent"
+                asset={stackedAsset}
+                on:click={() => (asset = stackedAsset)}
+                on:mouse-event={(e) => handleStackedAssetMouseEvent(e, stackedAsset)}
+                readonly
+                thumbnailSize={stackedAsset.id == asset.id ? 65 : 60}
+                showStackedIcon={false}
+              />
+
+              {#if stackedAsset.id == asset.id}
+                <div class="w-full flex place-items-center place-content-center">
+                  <div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" />
+                </div>
+              {/if}
+            </div>
+          {/each}
+        </div>
+      </div>
+    {/if}
   </div>
 
+  <!-- Stack & Stack Controller -->
+
   {#if !isSlideshowMode && showNavigation}
     <div class="z-[999] col-span-1 col-start-4 row-span-1 row-start-2 mb-[60px] justify-self-end">
       <NavigationArea on:click={navigateAssetForward}><Icon path={mdiChevronRight} size="36" /></NavigationArea>
@@ -458,7 +571,7 @@
     <div
       transition:fly={{ duration: 150 }}
       id="detail-panel"
-      class="z-[1002] row-span-full w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
+      class="z-[1002] row-start-1 row-span-5 w-[360px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg"
       translate="yes"
     >
       <DetailPanel
@@ -512,4 +625,27 @@
   #immich-asset-viewer {
     contain: layout;
   }
+
+  .horizontal-scrollbar::-webkit-scrollbar {
+    width: 8px;
+    height: 10px;
+  }
+
+  /* Track */
+  .horizontal-scrollbar::-webkit-scrollbar-track {
+    background: #000000;
+    border-radius: 16px;
+  }
+
+  /* Handle */
+  .horizontal-scrollbar::-webkit-scrollbar-thumb {
+    background: rgba(159, 159, 159, 0.408);
+    border-radius: 16px;
+  }
+
+  /* Handle on hover */
+  .horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
+    background: #adcbfa;
+    border-radius: 16px;
+  }
 </style>

+ 7 - 0
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;
 

+ 3 - 2
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 @@
 
 <div
   bind:this={element}
-  transition:fade={{ duration: 150 }}
+  transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
   class="flex h-full select-none place-content-center place-items-center"
 >
   {#await loadAssetData({ loadOriginal: false })}
@@ -124,7 +125,7 @@
   {:then}
     <div bind:this={imgElement} class="h-full w-full">
       <img
-        transition:fade={{ duration: 150 }}
+        transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
         src={assetData}
         alt={asset.id}
         class="h-full w-full object-contain"

+ 36 - 4
web/src/lib/components/assets/thumbnail/thumbnail.svelte

@@ -9,6 +9,7 @@
   import VideoThumbnail from './video-thumbnail.svelte';
   import {
     mdiArchiveArrowDownOutline,
+    mdiCameraBurst,
     mdiCheckCircle,
     mdiHeart,
     mdiImageBrokenVariant,
@@ -18,7 +19,11 @@
   } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
 
-  const dispatch = createEventDispatcher();
+  const dispatch = createEventDispatcher<{
+    click: { asset: AssetResponseDto };
+    select: { asset: AssetResponseDto };
+    'mouse-event': { isMouseOver: boolean; selectedGroupIndex: number };
+  }>();
 
   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;
+  };
 </script>
 
 <IntersectionObserver once={false} let:intersecting>
@@ -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}
-      <div class="absolute z-20 h-full w-full">
+      <div class="absolute z-20 h-full w-full {className}">
         <!-- Select asset button  -->
         {#if !readonly && (mouseOver || selected || selectionCandidate)}
           <button
@@ -140,6 +157,21 @@
           </div>
         {/if}
 
+        <!-- Stacked asset -->
+
+        {#if asset.stackCount && showStackedIcon}
+          <div
+            class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == null
+              ? 'top-0 right-0'
+              : 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
+          >
+            <span class="pr-2 pt-2 flex place-items-center gap-1">
+              <p>{asset.stackCount}</p>
+              <Icon path={mdiCameraBurst} size="24" />
+            </span>
+          </div>
+        {/if}
+
         {#if asset.resized}
           <ImageThumbnail
             url={api.getAssetThumbnailUrl(asset.id, format)}

+ 57 - 0
web/src/lib/components/photos-page/actions/stack-action.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
+  import { api } from '@api';
+  import { OnStack, getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import {
+    NotificationType,
+    notificationController,
+  } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
+
+  export let onStack: OnStack | undefined = undefined;
+
+  const { getAssets, clearSelect } = getAssetControlContext();
+
+  const handleStack = async () => {
+    try {
+      const assets = Array.from(getAssets());
+      const parent = assets.at(0);
+
+      if (parent == undefined) {
+        return;
+      }
+
+      const children = assets.slice(1);
+      const ids = children.map(({ id }) => id);
+
+      if (children.length > 0) {
+        await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } });
+      }
+
+      let childrenCount = parent.stackCount ?? 0;
+      for (const asset of children) {
+        asset.stackParentId = parent?.id;
+        // Add grand-children's count to new parent
+        childrenCount += asset.stackCount == null ? 1 : asset.stackCount + 1;
+        // Reset children stack info
+        asset.stackCount = null;
+        asset.stack = [];
+      }
+
+      parent.stackCount = childrenCount;
+      onStack?.(ids);
+
+      notificationController.show({
+        message: `Stacked ${ids.length + 1} assets`,
+        type: NotificationType.Info,
+        timeout: 1500,
+      });
+
+      clearSelect();
+    } catch (error) {
+      handleError(error, `Unable to stack`);
+    }
+  };
+</script>
+
+<MenuOption text="Stack" on:click={handleStack} />

+ 2 - 0
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -20,6 +20,7 @@
   export let isSelectionMode = false;
   export let viewport: Viewport;
   export let singleSelect = false;
+  export let withStacked = false;
 
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
@@ -178,6 +179,7 @@
             style="width: {box.width}px; height: {box.height}px; top: {box.top}px; left: {box.left}px"
           >
             <Thumbnail
+              showStackedIcon={withStacked}
               {asset}
               {groupIndex}
               on:click={() => assetClickHandler(asset, groupAssets, groupTitle)}

+ 4 - 0
web/src/lib/components/photos-page/asset-grid.svelte

@@ -25,6 +25,7 @@
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
   export let removeAction: AssetAction | null = null;
+  export let withStacked = false;
 
   $: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
   export let forceDelete = false;
@@ -365,6 +366,7 @@
           <div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
             {#if intersecting}
               <AssetDateGroup
+                {withStacked}
                 {assetStore}
                 {assetInteractionStore}
                 {isSelectionMode}
@@ -389,6 +391,7 @@
 <Portal target="body">
   {#if $showAssetViewer}
     <AssetViewer
+      {withStacked}
       {assetStore}
       asset={$viewingAsset}
       force={forceDelete || !isTrashEnabled}
@@ -399,6 +402,7 @@
       on:unarchived={({ detail: asset }) => handleAction(asset, AssetAction.UNARCHIVE)}
       on:favorite={({ detail: asset }) => handleAction(asset, AssetAction.FAVORITE)}
       on:unfavorite={({ detail: asset }) => handleAction(asset, AssetAction.UNFAVORITE)}
+      on:unstack={() => handleClose()}
     />
   {/if}
 </Portal>

+ 1 - 0
web/src/lib/components/photos-page/asset-select-control-bar.svelte

@@ -5,6 +5,7 @@
   export type OnRestore = (ids: string[]) => void;
   export type OnArchive = (ids: string[], isArchived: boolean) => void;
   export type OnFavorite = (ids: string[], favorite: boolean) => void;
+  export type OnStack = (ids: string[]) => void;
 
   export interface AssetControlContext {
     // Wrap assets in a function, because context isn't reactive.

+ 2 - 1
web/src/lib/stores/assets.store.ts

@@ -222,6 +222,7 @@ export class AssetStore {
       }
 
       bucket.assets = assets;
+
       this.emit(true);
     } catch (error) {
       handleError(error, 'Failed to load assets');
@@ -251,7 +252,7 @@ export class AssetStore {
     return scrollTimeline ? delta : 0;
   }
 
-  private addAsset(asset: AssetResponseDto): void {
+  addAsset(asset: AssetResponseDto): void {
     if (
       this.assetToBucket[asset.id] ||
       this.options.userId ||

+ 4 - 0
web/src/lib/stores/stacked-asset.store.ts

@@ -0,0 +1,4 @@
+import { writable } from 'svelte/store';
+import type { AssetResponseDto } from '../../api/open-api';
+
+export const stackAssetsStore = writable<AssetResponseDto[]>([]);

+ 12 - 2
web/src/routes/(user)/photos/+page.svelte

@@ -7,6 +7,7 @@
   import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
   import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
   import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
+  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
   import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
   import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
   import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
@@ -25,7 +26,7 @@
 
   let { isViewing: showAssetViewer } = assetViewingStore;
   let handleEscapeKey = false;
-  const assetStore = new AssetStore({ isArchived: false });
+  const assetStore = new AssetStore({ isArchived: false, withStacked: true });
   const assetInteractionStore = createAssetInteractionStore();
   const { isMultiSelectState, selectedAssets } = assetInteractionStore;
 
@@ -62,13 +63,22 @@
       <FavoriteAction menuItem removeFavorite={isAllFavorite} />
       <DownloadAction menuItem />
       <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
+      {#if $selectedAssets.size > 1}
+        <StackAction onStack={(ids) => assetStore.removeAssets(ids)} />
+      {/if}
       <AssetJobActions />
     </AssetSelectContextMenu>
   </AssetSelectControlBar>
 {/if}
 
 <UserPageLayout user={data.user} hideNavbar={$isMultiSelectState} showUploadButton scrollbar={false}>
-  <AssetGrid {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} on:escape={handleEscape}>
+  <AssetGrid
+    {assetStore}
+    {assetInteractionStore}
+    removeAction={AssetAction.ARCHIVE}
+    on:escape={handleEscape}
+    withStacked
+  >
     {#if data.user.memoriesEnabled}
       <MemoryLane />
     {/if}