فهرست منبع

feat(web): use time buckets of person detail page (3) (#3557)

* feat: add personId to time bucket endpoints

* chore: open api

* feat(web): time bucket on person detail page
Jason Rasmussen 1 سال پیش
والد
کامیت
ff32506c5e

+ 36 - 10
cli/src/api/open-api/api.ts

@@ -5111,13 +5111,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} timeBucket 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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
@@ -5155,6 +5156,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['albumId'] = albumId;
             }
 
+            if (personId !== undefined) {
+                localVarQueryParameter['personId'] = personId;
+            }
+
             if (isArchived !== undefined) {
                 localVarQueryParameter['isArchived'] = isArchived;
             }
@@ -5430,13 +5435,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {TimeBucketSize} size 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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`;
@@ -5472,6 +5478,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['albumId'] = albumId;
             }
 
+            if (personId !== undefined) {
+                localVarQueryParameter['personId'] = personId;
+            }
+
             if (isArchived !== undefined) {
                 localVarQueryParameter['isArchived'] = isArchived;
             }
@@ -5986,14 +5996,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} timeBucket 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key, options);
+        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6055,14 +6066,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {TimeBucketSize} size 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key, options);
+        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6256,7 +6268,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.isArchived, requestParameters.isFavorite, 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.key, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -6308,7 +6320,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.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
+            return localVarFp.getTimeBuckets(requestParameters.size, requestParameters.userId, requestParameters.albumId, requestParameters.personId, requestParameters.isArchived, requestParameters.isFavorite, requestParameters.key, options).then((request) => request(axios, basePath));
         },
         /**
          * Get all asset of a device that are in the database, ID only.
@@ -6625,6 +6637,13 @@ export interface AssetApiGetByTimeBucketRequest {
      */
     readonly albumId?: string
 
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetApiGetByTimeBucket
+     */
+    readonly personId?: string
+
     /**
      * 
      * @type {boolean}
@@ -6758,6 +6777,13 @@ export interface AssetApiGetTimeBucketsRequest {
      */
     readonly albumId?: string
 
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetApiGetTimeBuckets
+     */
+    readonly personId?: string
+
     /**
      * 
      * @type {boolean}
@@ -7111,7 +7137,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.isArchived, requestParameters.isFavorite, 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.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**
@@ -7175,7 +7201,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.isArchived, requestParameters.isFavorite, 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.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

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

@@ -671,7 +671,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, isArchived, isFavorite, key)
+> List<AssetResponseDto> getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key)
 
 
 
@@ -698,12 +698,13 @@ final size = ; // TimeBucketSize |
 final timeBucket = timeBucket_example; // String | 
 final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key);
+    final result = api_instance.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getByTimeBucket: $e\n');
@@ -718,6 +719,7 @@ Name | Type | Description  | Notes
  **timeBucket** | **String**|  | 
  **userId** | **String**|  | [optional] 
  **albumId** | **String**|  | [optional] 
+ **personId** | **String**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 
@@ -1017,7 +1019,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, isArchived, isFavorite, key)
+> List<TimeBucketResponseDto> getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key)
 
 
 
@@ -1043,12 +1045,13 @@ final api_instance = AssetApi();
 final size = ; // TimeBucketSize | 
 final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final isArchived = true; // bool | 
 final isFavorite = true; // bool | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key);
+    final result = api_instance.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getTimeBuckets: $e\n');
@@ -1062,6 +1065,7 @@ Name | Type | Description  | Notes
  **size** | [**TimeBucketSize**](.md)|  | 
  **userId** | **String**|  | [optional] 
  **albumId** | **String**|  | [optional] 
+ **personId** | **String**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **key** | **String**|  | [optional] 

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

@@ -677,12 +677,14 @@ class AssetApi {
   ///
   /// * [String] albumId:
   ///
+  /// * [String] personId:
+  ///
   /// * [bool] isArchived:
   ///
   /// * [bool] isFavorite:
   ///
   /// * [String] key:
-  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, bool? isArchived, bool? isFavorite, String? key, }) async {
+  Future<Response> getByTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/time-bucket';
 
@@ -700,6 +702,9 @@ class AssetApi {
     if (albumId != null) {
       queryParams.addAll(_queryParams('', 'albumId', albumId));
     }
+    if (personId != null) {
+      queryParams.addAll(_queryParams('', 'personId', personId));
+    }
     if (isArchived != null) {
       queryParams.addAll(_queryParams('', 'isArchived', isArchived));
     }
@@ -735,13 +740,15 @@ class AssetApi {
   ///
   /// * [String] albumId:
   ///
+  /// * [String] personId:
+  ///
   /// * [bool] isArchived:
   ///
   /// * [bool] isFavorite:
   ///
   /// * [String] key:
-  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, bool? isArchived, bool? isFavorite, String? key, }) async {
-    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
+  Future<List<AssetResponseDto>?> getByTimeBucket(TimeBucketSize size, String timeBucket, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
+    final response = await getByTimeBucketWithHttpInfo(size, timeBucket,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -1056,12 +1063,14 @@ class AssetApi {
   ///
   /// * [String] albumId:
   ///
+  /// * [String] personId:
+  ///
   /// * [bool] isArchived:
   ///
   /// * [bool] isFavorite:
   ///
   /// * [String] key:
-  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, bool? isArchived, bool? isFavorite, String? key, }) async {
+  Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/time-buckets';
 
@@ -1079,6 +1088,9 @@ class AssetApi {
     if (albumId != null) {
       queryParams.addAll(_queryParams('', 'albumId', albumId));
     }
+    if (personId != null) {
+      queryParams.addAll(_queryParams('', 'personId', personId));
+    }
     if (isArchived != null) {
       queryParams.addAll(_queryParams('', 'isArchived', isArchived));
     }
@@ -1111,13 +1123,15 @@ class AssetApi {
   ///
   /// * [String] albumId:
   ///
+  /// * [String] personId:
+  ///
   /// * [bool] isArchived:
   ///
   /// * [bool] isFavorite:
   ///
   /// * [String] key:
-  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, bool? isArchived, bool? isFavorite, String? key, }) async {
-    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
+  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? userId, String? albumId, String? personId, bool? isArchived, bool? isFavorite, String? key, }) async {
+    final response = await getTimeBucketsWithHttpInfo(size,  userId: userId, albumId: albumId, personId: personId, isArchived: isArchived, isFavorite: isFavorite, key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

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

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

+ 18 - 0
server/immich-openapi-specs.json

@@ -1684,6 +1684,15 @@
               "type": "string"
             }
           },
+          {
+            "name": "personId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
           {
             "name": "isArchived",
             "required": false,
@@ -1787,6 +1796,15 @@
               "type": "string"
             }
           },
+          {
+            "name": "personId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
           {
             "name": "isArchived",
             "required": false,

+ 1 - 0
server/src/domain/asset/asset.repository.ts

@@ -57,6 +57,7 @@ export interface TimeBucketOptions {
   isArchived?: boolean;
   isFavorite?: boolean;
   albumId?: string;
+  personId?: string;
 }
 
 export interface TimeBucketItem {

+ 3 - 0
server/src/domain/asset/dto/time-bucket.dto.ts

@@ -16,6 +16,9 @@ export class TimeBucketDto {
   @ValidateUUID({ optional: true })
   albumId?: string;
 
+  @ValidateUUID({ optional: true })
+  personId?: string;
+
   @IsOptional()
   @IsBoolean()
   @Transform(toBoolean)

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

@@ -386,7 +386,7 @@ export class AssetRepository implements IAssetRepository {
   }
 
   private getBuilder(userId: string, options: TimeBucketOptions) {
-    const { isArchived, isFavorite, albumId } = options;
+    const { isArchived, isFavorite, albumId, personId } = options;
 
     let builder = this.repository
       .createQueryBuilder('asset')
@@ -406,6 +406,13 @@ export class AssetRepository implements IAssetRepository {
       builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
     }
 
+    if (personId !== undefined) {
+      builder = builder
+        .innerJoin('asset.faces', 'faces')
+        .innerJoin('faces.person', 'person')
+        .andWhere('person.id = :personId', { personId });
+    }
+
     return builder;
   }
 }

+ 40 - 12
web/src/api/open-api/api.ts

@@ -5120,13 +5120,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {string} timeBucket 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getByTimeBucket: async (size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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
@@ -5164,6 +5165,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['albumId'] = albumId;
             }
 
+            if (personId !== undefined) {
+                localVarQueryParameter['personId'] = personId;
+            }
+
             if (isArchived !== undefined) {
                 localVarQueryParameter['isArchived'] = isArchived;
             }
@@ -5439,13 +5444,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {TimeBucketSize} size 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getTimeBuckets: async (size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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`;
@@ -5481,6 +5487,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 localVarQueryParameter['albumId'] = albumId;
             }
 
+            if (personId !== undefined) {
+                localVarQueryParameter['personId'] = personId;
+            }
+
             if (isArchived !== undefined) {
                 localVarQueryParameter['isArchived'] = isArchived;
             }
@@ -5995,14 +6005,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {string} timeBucket 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key, options);
+        async getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: 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, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6064,14 +6075,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {TimeBucketSize} size 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key, options);
+        async getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimeBucketResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -6276,14 +6288,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {string} timeBucket 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
-            return localVarFp.getByTimeBucket(size, timeBucket, userId, albumId, isArchived, isFavorite, key, options).then((request) => request(axios, basePath));
+        getByTimeBucket(size: TimeBucketSize, timeBucket: string, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
+            return localVarFp.getByTimeBucket(size, timeBucket, userId, albumId, personId, isArchived, isFavorite, key, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -6339,14 +6352,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {TimeBucketSize} size 
          * @param {string} [userId] 
          * @param {string} [albumId] 
+         * @param {string} [personId] 
          * @param {boolean} [isArchived] 
          * @param {boolean} [isFavorite] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: any): AxiosPromise<Array<TimeBucketResponseDto>> {
-            return localVarFp.getTimeBuckets(size, userId, albumId, isArchived, isFavorite, key, options).then((request) => request(axios, basePath));
+        getTimeBuckets(size: TimeBucketSize, userId?: string, albumId?: string, personId?: string, isArchived?: boolean, isFavorite?: boolean, key?: string, options?: any): AxiosPromise<Array<TimeBucketResponseDto>> {
+            return localVarFp.getTimeBuckets(size, userId, albumId, personId, isArchived, isFavorite, key, options).then((request) => request(axios, basePath));
         },
         /**
          * Get all asset of a device that are in the database, ID only.
@@ -6679,6 +6693,13 @@ export interface AssetApiGetByTimeBucketRequest {
      */
     readonly albumId?: string
 
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetApiGetByTimeBucket
+     */
+    readonly personId?: string
+
     /**
      * 
      * @type {boolean}
@@ -6812,6 +6833,13 @@ export interface AssetApiGetTimeBucketsRequest {
      */
     readonly albumId?: string
 
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetApiGetTimeBuckets
+     */
+    readonly personId?: string
+
     /**
      * 
      * @type {boolean}
@@ -7165,7 +7193,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.isArchived, requestParameters.isFavorite, 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.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**
@@ -7229,7 +7257,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.isArchived, requestParameters.isFavorite, 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.key, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 2 - 0
web/src/app.css

@@ -15,6 +15,8 @@
 
 :root {
   font-family: 'Work Sans', sans-serif;
+  /* Used by layouts to ensure proper spacing between navbar and content */
+  --navbar-height: calc(theme(spacing.18) + 4px);
 }
 
 html {

+ 1 - 1
web/src/lib/components/album-page/asset-selection.svelte

@@ -69,6 +69,6 @@
     </svelte:fragment>
   </ControlAppBar>
   <section class="grid h-screen bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
-    <AssetGrid {assetStore} {assetInteractionStore} isAlbumSelectionMode={true} />
+    <AssetGrid {assetStore} {assetInteractionStore} isSelectionMode={true} />
   </section>
 </section>

+ 0 - 36
web/src/lib/components/faces-page/face-thumbnail-selector.svelte

@@ -1,36 +0,0 @@
-<script lang="ts">
-  import type { AssetResponseDto } from '@api';
-  import { createEventDispatcher } from 'svelte';
-  import { quintOut } from 'svelte/easing';
-  import { fly } from 'svelte/transition';
-  import ControlAppBar from '../shared-components/control-app-bar.svelte';
-  import AssetSelectionViewer from '../shared-components/gallery-viewer/asset-selection-viewer.svelte';
-
-  const dispatch = createEventDispatcher();
-
-  export let assets: AssetResponseDto[];
-
-  let selectedAsset: AssetResponseDto | undefined = undefined;
-
-  const handleSelectedAsset = async (event: CustomEvent) => {
-    const { asset }: { asset: AssetResponseDto } = event.detail;
-    selectedAsset = asset;
-    onClose();
-  };
-
-  const onClose = () => {
-    dispatch('go-back', { selectedAsset });
-  };
-</script>
-
-<section
-  transition:fly={{ y: 500, duration: 100, easing: quintOut }}
-  class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
->
-  <ControlAppBar on:close-button-click={onClose}>
-    <svelte:fragment slot="leading">Select feature photo</svelte:fragment>
-  </ControlAppBar>
-  <section class="bg-immich-bg pl-[70px] pt-[100px] dark:bg-immich-dark-bg">
-    <AssetSelectionViewer {assets} on:select={handleSelectedAsset} />
-  </section>
-</section>

+ 1 - 1
web/src/lib/components/faces-page/merge-face-selector.svelte

@@ -118,7 +118,7 @@
         </div>
       </div>
       <div
-        class="overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
+        class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
         style:max-height={screenHeight - 200 - 200 + 'px'}
       >
         <div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">

+ 87 - 85
web/src/lib/components/faces-page/merge-suggestion-modal.svelte

@@ -1,13 +1,13 @@
 <script lang="ts">
-  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
+  import { api, type PersonResponseDto } from '@api';
   import { createEventDispatcher } from 'svelte';
+  import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import Close from 'svelte-material-icons/Close.svelte';
-  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
-  import type { PersonResponseDto } from '../../../api/open-api';
-  import { api } from '@api';
   import Merge from 'svelte-material-icons/Merge.svelte';
+  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
   import Button from '../elements/buttons/button.svelte';
-  import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
+  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
 
   const dispatch = createEventDispatcher<{
     reject: void;
@@ -39,90 +39,92 @@
   };
 </script>
 
-<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
-  <div
-    class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
-  >
-    <div class="relative flex items-center justify-between">
-      <h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
-        Merge faces - {title}
-      </h1>
-      <CircleIconButton logo={Close} on:click={() => dispatch('close')} />
-    </div>
-
-    <div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
-      {#if !choosePersonToMerge}
-        <div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
-          <ImageThumbnail
-            circle
-            shadow
-            url={api.getPeopleThumbnailUrl(personMerge1.id)}
-            altText={personMerge1.name}
-            widthStyle="100%"
-          />
-        </div>
-        <div class="mx-0.5 flex md:mx-2">
-          <CircleIconButton
-            logo={Merge}
-            on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
-          />
-        </div>
+<FullScreenModal on:clickOutside={() => dispatch('close')}>
+  <div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
+    <div
+      class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
+    >
+      <div class="relative flex items-center justify-between">
+        <h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
+          Merge faces - {title}
+        </h1>
+        <CircleIconButton logo={Close} on:click={() => dispatch('close')} />
+      </div>
 
-        <button
-          disabled={potentialMergePeople.length === 0}
-          class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
-          on:click={() => {
-            if (potentialMergePeople.length > 0) {
-              choosePersonToMerge = !choosePersonToMerge;
-            }
-          }}
-        >
-          <ImageThumbnail
-            border={potentialMergePeople.length !== 0}
-            circle
-            shadow
-            url={api.getPeopleThumbnailUrl(personMerge2.id)}
-            altText={personMerge2.name}
-            widthStyle="100%"
-          />
-        </button>
-      {:else}
-        <div class="grid w-full grid-cols-1 gap-2">
-          <div class="px-2">
-            <button on:click={() => (choosePersonToMerge = false)}> <ArrowLeft /></button>
+      <div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
+        {#if !choosePersonToMerge}
+          <div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
+            <ImageThumbnail
+              circle
+              shadow
+              url={api.getPeopleThumbnailUrl(personMerge1.id)}
+              altText={personMerge1.name}
+              widthStyle="100%"
+            />
+          </div>
+          <div class="mx-0.5 flex md:mx-2">
+            <CircleIconButton
+              logo={Merge}
+              on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
+            />
           </div>
-          <div class="flex items-center justify-center">
-            <div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
-              {#each potentialMergePeople as person (person.id)}
-                <div class="h-24 w-24 md:h-28 md:w-28">
-                  <button class="p-2" on:click={() => changePersonToMerge(person)}>
-                    <ImageThumbnail
-                      border={true}
-                      circle
-                      shadow
-                      url={api.getPeopleThumbnailUrl(person.id)}
-                      altText={person.name}
-                      widthStyle="100%"
-                      on:click={() => changePersonToMerge(person)}
-                    />
-                  </button>
-                </div>
-              {/each}
+
+          <button
+            disabled={potentialMergePeople.length === 0}
+            class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
+            on:click={() => {
+              if (potentialMergePeople.length > 0) {
+                choosePersonToMerge = !choosePersonToMerge;
+              }
+            }}
+          >
+            <ImageThumbnail
+              border={potentialMergePeople.length !== 0}
+              circle
+              shadow
+              url={api.getPeopleThumbnailUrl(personMerge2.id)}
+              altText={personMerge2.name}
+              widthStyle="100%"
+            />
+          </button>
+        {:else}
+          <div class="grid w-full grid-cols-1 gap-2">
+            <div class="px-2">
+              <button on:click={() => (choosePersonToMerge = false)}> <ArrowLeft /></button>
+            </div>
+            <div class="flex items-center justify-center">
+              <div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
+                {#each potentialMergePeople as person (person.id)}
+                  <div class="h-24 w-24 md:h-28 md:w-28">
+                    <button class="p-2" on:click={() => changePersonToMerge(person)}>
+                      <ImageThumbnail
+                        border={true}
+                        circle
+                        shadow
+                        url={api.getPeopleThumbnailUrl(person.id)}
+                        altText={person.name}
+                        widthStyle="100%"
+                        on:click={() => changePersonToMerge(person)}
+                      />
+                    </button>
+                  </div>
+                {/each}
+              </div>
             </div>
           </div>
-        </div>
-      {/if}
-    </div>
+        {/if}
+      </div>
 
-    <div class="flex px-4 md:px-8 md:pt-4">
-      <h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
-    </div>
-    <div class="flex px-4 pt-2 md:px-8">
-      <p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
-    </div>
-    <div class="mt-8 flex w-full gap-4 px-4 pb-4">
-      <Button color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
-      <Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
+      <div class="flex px-4 md:px-8 md:pt-4">
+        <h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
+      </div>
+      <div class="flex px-4 pt-2 md:px-8">
+        <p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
+      </div>
+      <div class="mt-8 flex w-full gap-4 px-4 pb-4">
+        <Button color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
+        <Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
+      </div>
     </div>
   </div>
-</div>
+</FullScreenModal>

+ 5 - 8
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -18,8 +18,9 @@
   export let assets: AssetResponseDto[];
   export let bucketDate: string;
   export let bucketHeight: number;
-  export let isAlbumSelectionMode = false;
+  export let isSelectionMode = false;
   export let viewport: Viewport;
+  export let singleSelect = false;
 
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
@@ -90,16 +91,12 @@
     assetsInDateGroup: AssetResponseDto[],
     dateGroupTitle: string,
   ) => {
-    if (isAlbumSelectionMode) {
+    if (isSelectionMode || $isMultiSelectState) {
       assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
       return;
     }
 
-    if ($isMultiSelectState) {
-      assetSelectHandler(asset, assetsInDateGroup, dateGroupTitle);
-    } else {
-      assetViewingStore.setAssetId(asset.id);
-    }
+    assetViewingStore.setAssetId(asset.id);
   };
 
   const selectAssetGroupHandler = (selectAssetGroupHandler: AssetResponseDto[], dateGroupTitle: string) => {
@@ -166,7 +163,7 @@
         class="mb-2 flex h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
         style="width: {geometry[groupIndex].containerWidth}px"
       >
-        {#if (hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle)}
+        {#if !singleSelect && ((hoveredDateGroup == dateGroupTitle && isMouseOverGroup) || $selectedGroup.has(dateGroupTitle))}
           <div
             transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
             class="inline-block px-2 hover:cursor-pointer"

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

@@ -4,7 +4,7 @@
   import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
   import type { AssetResponseDto } from '@api';
   import { DateTime } from 'luxon';
-  import { onDestroy, onMount } from 'svelte';
+  import { createEventDispatcher, onDestroy, onMount } from 'svelte';
   import AssetViewer from '../asset-viewer/asset-viewer.svelte';
   import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
   import Portal from '../shared-components/portal/portal.svelte';
@@ -19,7 +19,8 @@
   import { isSearchEnabled } from '$lib/stores/search.store';
   import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
 
-  export let isAlbumSelectionMode = false;
+  export let isSelectionMode = false;
+  export let singleSelect = false;
   export let assetStore: AssetStore;
   export let assetInteractionStore: AssetInteractionStore;
   export let removeAction: AssetAction | null = null;
@@ -33,6 +34,7 @@
   $: timelineY = element?.scrollTop || 0;
 
   const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
+  const dispatch = createEventDispatcher<{ select: AssetResponseDto }>();
 
   onMount(async () => {
     document.addEventListener('keydown', onKeyboardPress);
@@ -173,11 +175,17 @@
   };
 
   const handleSelectAssets = async (e: CustomEvent) => {
-    const asset = e.detail.asset;
+    const asset = e.detail.asset as AssetResponseDto;
     if (!asset) {
       return;
     }
 
+    dispatch('select', asset);
+
+    if (singleSelect) {
+      element.scrollTop = 0;
+    }
+
     const rangeSelection = $assetSelectionCandidates.size > 0;
     const deselect = $selectedAssets.has(asset);
 
@@ -308,7 +316,8 @@
               <AssetDateGroup
                 {assetStore}
                 {assetInteractionStore}
-                {isAlbumSelectionMode}
+                {isSelectionMode}
+                {singleSelect}
                 on:shift={handleScrollTimeline}
                 on:selectAssetCandidates={handleSelectAssetCandidates}
                 on:selectAssets={handleSelectAssets}

+ 0 - 7
web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte

@@ -132,10 +132,3 @@
     </div>
   </div>
 </section>
-
-<style>
-  :root {
-    /* Used by layouts to ensure proper spacing between navbar and content */
-    --navbar-height: calc(theme(spacing.18) + 4px);
-  }
-</style>

+ 0 - 2
web/src/routes/(user)/people/[personId]/+page.server.ts

@@ -9,12 +9,10 @@ export const load = (async ({ locals, parent, params }) => {
   }
 
   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
-  const { data: assets } = await locals.api.personApi.getPersonAssets({ id: params.personId });
   const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: false });
 
   return {
     user,
-    assets,
     person,
     people,
     meta: {

+ 150 - 130
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -3,59 +3,68 @@
   import { page } from '$app/stores';
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
+  import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
+  import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
   import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
   import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
   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 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';
   import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
+  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
-  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
+  import {
+    NotificationType,
+    notificationController,
+  } from '$lib/components/shared-components/notification/notification';
   import { AppRoute } from '$lib/constants';
+  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
+  import { AssetStore } from '$lib/stores/assets.store';
   import { handleError } from '$lib/utils/handle-error';
-  import { AssetResponseDto, PersonResponseDto, api } from '@api';
+  import { AssetResponseDto, PersonResponseDto, TimeBucketSize, api } from '@api';
+  import { onMount } from 'svelte';
   import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import type { PageData } from './$types';
-  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
-  import SelectAll from 'svelte-material-icons/SelectAll.svelte';
-  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
-  import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte';
-  import {
-    NotificationType,
-    notificationController,
-  } from '$lib/components/shared-components/notification/notification';
-  import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
-  import { onMount } from 'svelte';
-  import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
-  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
 
   export let data: PageData;
+
+  enum ViewMode {
+    VIEW_ASSETS = 'view-assets',
+    SELECT_FACE = 'select-face',
+    MERGE_FACES = 'merge-faces',
+    SUGGEST_MERGE = 'suggest-merge',
+  }
+
+  const assetStore = new AssetStore({
+    size: TimeBucketSize.Month,
+    isArchived: false,
+    personId: data.person.id,
+  });
+  const assetInteractionStore = createAssetInteractionStore();
+  const { selectedAssets, isMultiSelectState } = assetInteractionStore;
+
+  let viewMode: ViewMode = ViewMode.VIEW_ASSETS;
   let isEditingName = false;
-  let showFaceThumbnailSelection = false;
-  let showMergeFacePanel = false;
   let previousRoute: string = AppRoute.EXPLORE;
-  let selectedAssets: Set<AssetResponseDto> = new Set();
-  let showMergeModal = false;
   let people = data.people.people;
   let personMerge1: PersonResponseDto;
   let personMerge2: PersonResponseDto;
 
   let personName = '';
 
-  $: isMultiSelectionMode = selectedAssets.size > 0;
-  $: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
-  $: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
-
-  $: showAssets = !showMergeFacePanel && !showFaceThumbnailSelection;
+  $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
+  $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
 
   onMount(() => {
     const action = $page.url.searchParams.get('action');
     if (action == 'merge') {
-      showMergeFacePanel = true;
+      viewMode = ViewMode.MERGE_FACES;
     }
   });
   afterNavigate(({ from }) => {
@@ -65,35 +74,29 @@
     }
   });
 
-  const onAssetDelete = (assetId: string) => {
-    data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
-  };
-  const handleSelectAll = () => {
-    selectedAssets = new Set(data.assets);
-  };
+  const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => {
+    if (viewMode !== ViewMode.SELECT_FACE) {
+      return;
+    }
 
-  const handleSelectFeaturePhoto = async (event: CustomEvent) => {
-    showFaceThumbnailSelection = false;
+    await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
 
-    const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail;
+    // TODO: Replace by Websocket in the future
+    notificationController.show({
+      message: 'Feature photo updated, refresh page to see changes',
+      type: NotificationType.Info,
+    });
 
-    if (selectedAsset) {
-      await api.personApi.updatePerson({
-        id: data.person.id,
-        personUpdateDto: { featureFaceAssetId: selectedAsset.id },
-      });
+    assetInteractionStore.clearMultiselect();
+    // scroll to top
 
-      // TODO: Replace by Websocket in the future
-      notificationController.show({
-        message: 'Feature photo updated, refresh page to see changes',
-        type: NotificationType.Info,
-      });
-    }
+    viewMode = ViewMode.VIEW_ASSETS;
   };
 
   const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
     const [personToMerge, personToBeMergedIn] = response;
-    showMergeModal = false;
+    viewMode = ViewMode.VIEW_ASSETS;
+    isEditingName = false;
     try {
       await api.personApi.mergePerson({
         id: personToBeMergedIn.id,
@@ -116,7 +119,7 @@
   };
 
   const changeName = async () => {
-    showMergeModal = false;
+    viewMode = ViewMode.VIEW_ASSETS;
     data.person.name = personName;
     try {
       isEditingName = false;
@@ -142,6 +145,14 @@
     }
   };
 
+  const handleCancelEditName = () => {
+    if (viewMode === ViewMode.SUGGEST_MERGE) {
+      return;
+    }
+
+    isEditingName = false;
+  };
+
   const handleNameChange = async (name: string) => {
     personName = name;
 
@@ -156,102 +167,111 @@
     if (existingPerson) {
       personMerge2 = existingPerson;
       personMerge1 = data.person;
-      showMergeModal = true;
+      viewMode = ViewMode.SUGGEST_MERGE;
       return;
     }
     changeName();
   };
 </script>
 
-{#if showMergeModal}
-  <FullScreenModal on:clickOutside={() => (showMergeModal = false)}>
-    <MergeSuggestionModal
-      {personMerge1}
-      {personMerge2}
-      {people}
-      on:close={() => (showMergeModal = false)}
-      on:reject={() => changeName()}
-      on:confirm={(event) => handleMergeSameFace(event.detail)}
-    />
-  </FullScreenModal>
+{#if viewMode === ViewMode.SUGGEST_MERGE}
+  <MergeSuggestionModal
+    {personMerge1}
+    {personMerge2}
+    {people}
+    on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    on:reject={() => changeName()}
+    on:confirm={(event) => handleMergeSameFace(event.detail)}
+  />
 {/if}
 
-{#if isMultiSelectionMode}
-  <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
-    <CreateSharedLink />
-    <CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
-    <AssetSelectContextMenu icon={Plus} title="Add">
-      <AddToAlbum />
-      <AddToAlbum shared />
-    </AssetSelectContextMenu>
-    <DeleteAssets {onAssetDelete} />
-    <AssetSelectContextMenu icon={DotsVertical} title="Add">
-      <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
-      <FavoriteAction menuItem removeFavorite={isAllFavorite} />
-      <ArchiveAction menuItem unarchive={isAllArchive} onAssetArchive={(asset) => onAssetDelete(asset.id)} />
-    </AssetSelectContextMenu>
-  </AssetSelectControlBar>
-{:else}
-  <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
-    <svelte:fragment slot="trailing">
-      <AssetSelectContextMenu icon={DotsVertical} title="Menu">
-        <MenuOption text="Change feature photo" on:click={() => (showFaceThumbnailSelection = true)} />
-        <MenuOption text="Merge face" on:click={() => (showMergeFacePanel = true)} />
-      </AssetSelectContextMenu>
-    </svelte:fragment>
-  </ControlAppBar>
+{#if viewMode === ViewMode.MERGE_FACES}
+  <MergeFaceSelector person={data.person} on:go-back={() => (viewMode = ViewMode.VIEW_ASSETS)} />
 {/if}
 
-<!-- Face information block -->
-<section class="flex place-items-center px-4 pt-24 sm:px-6">
-  {#if isEditingName}
-    <EditNameInput
-      person={data.person}
-      on:change={(event) => handleNameChange(event.detail)}
-      on:cancel={() => (isEditingName = false)}
-    />
+<header>
+  {#if $isMultiSelectState}
+    <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
+      <CreateSharedLink />
+      <SelectAllAssets {assetStore} {assetInteractionStore} />
+      <AssetSelectContextMenu icon={Plus} title="Add">
+        <AddToAlbum />
+        <AddToAlbum shared />
+      </AssetSelectContextMenu>
+      <DeleteAssets onAssetDelete={(assetId) => $assetStore.removeAsset(assetId)} />
+      <AssetSelectContextMenu icon={DotsVertical} title="Add">
+        <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
+        <FavoriteAction menuItem removeFavorite={isAllFavorite} />
+        <ArchiveAction
+          menuItem
+          unarchive={isAllArchive}
+          onAssetArchive={(asset) => $assetStore.removeAsset(asset.id)}
+        />
+      </AssetSelectContextMenu>
+    </AssetSelectControlBar>
   {:else}
-    <button on:click={() => (showFaceThumbnailSelection = true)}>
-      <ImageThumbnail
-        circle
-        shadow
-        url={api.getPeopleThumbnailUrl(data.person.id)}
-        altText={data.person.name}
-        widthStyle="3.375rem"
-        heightStyle="3.375rem"
-      />
-    </button>
+    {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE}
+      <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
+        <svelte:fragment slot="trailing">
+          <AssetSelectContextMenu icon={DotsVertical} title="Menu">
+            <MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_FACE)} />
+            <MenuOption text="Merge face" on:click={() => (viewMode = ViewMode.MERGE_FACES)} />
+          </AssetSelectContextMenu>
+        </svelte:fragment>
+      </ControlAppBar>
+    {/if}
 
-    <button
-      title="Edit name"
-      class="px-4 text-immich-primary dark:text-immich-dark-primary"
-      on:click={() => (isEditingName = true)}
-    >
-      {#if data.person.name}
-        <p class="py-2 font-medium">{data.person.name}</p>
-      {:else}
-        <p class="w-fit font-medium">Add a name</p>
-        <p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
-      {/if}
-    </button>
+    {#if viewMode === ViewMode.SELECT_FACE}
+      <ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
+        <svelte:fragment slot="leading">Select feature photo</svelte:fragment>
+      </ControlAppBar>
+    {/if}
   {/if}
-</section>
+</header>
 
-<!-- Gallery Block -->
-{#if showAssets}
-  <section class="relative mb-12 bg-immich-bg pt-8 dark:bg-immich-dark-bg sm:px-4">
-    <section class="immich-scrollbar relative overflow-y-scroll">
-      <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
-        <GalleryViewer assets={data.assets} showArchiveIcon={true} bind:selectedAssets />
-      </section>
-    </section>
-  </section>
-{/if}
-
-{#if showFaceThumbnailSelection}
-  <FaceThumbnailSelector assets={data.assets} on:go-back={handleSelectFeaturePhoto} />
-{/if}
+<main class="relative h-screen overflow-hidden bg-immich-bg pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
+  <AssetGrid
+    {assetStore}
+    {assetInteractionStore}
+    isSelectionMode={viewMode === ViewMode.SELECT_FACE}
+    singleSelect={viewMode === ViewMode.SELECT_FACE}
+    on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)}
+  >
+    {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE}
+      <!-- Face information block -->
+      <section class="flex place-items-center p-4 sm:px-6">
+        {#if isEditingName}
+          <EditNameInput
+            person={data.person}
+            on:change={(event) => handleNameChange(event.detail)}
+            on:cancel={() => handleCancelEditName()}
+          />
+        {:else}
+          <button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
+            <ImageThumbnail
+              circle
+              shadow
+              url={api.getPeopleThumbnailUrl(data.person.id)}
+              altText={data.person.name}
+              widthStyle="3.375rem"
+              heightStyle="3.375rem"
+            />
+          </button>
 
-{#if showMergeFacePanel}
-  <MergeFaceSelector person={data.person} on:go-back={() => (showMergeFacePanel = false)} />
-{/if}
+          <button
+            title="Edit name"
+            class="px-4 text-immich-primary dark:text-immich-dark-primary"
+            on:click={() => (isEditingName = true)}
+          >
+            {#if data.person.name}
+              <p class="py-2 font-medium">{data.person.name}</p>
+            {:else}
+              <p class="w-fit font-medium">Add a name</p>
+              <p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
+            {/if}
+          </button>
+        {/if}
+      </section>
+    {/if}
+  </AssetGrid>
+</main>