You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	fix: suggest people (#4566)
* fix: suggest people * feat: remove hidden people * add hidden people when merging faces * pr feedback * fix: don't use reactive statement * fixed section height * improve merging * fix: migration * fix migration * feat: add asset count * fix: test * rename endpoint * add server test * improve responsive design * fix: remove videos from live photos in the asset count * pr feedback * fix: rename asset count endpoint * fix: return firstname and lastname * fix: reset people only on error * fix: search * fix: responsive design & div flickering * fix: cleanup * chore: open api --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
		
							
								
								
									
										122
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										122
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { | ||||
|      */ | ||||
|     'thumbnailPath': string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface PersonStatisticsResponseDto | ||||
|  */ | ||||
| export interface PersonStatisticsResponseDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @memberof PersonStatisticsResponseDto | ||||
|      */ | ||||
|     'assets': number; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
| @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| 
 | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'id' is not null or undefined
 | ||||
|             assertParamExists('getPersonStatistics', 'id', id) | ||||
|             const localVarPath = `/person/{id}/statistics` | ||||
|                 .replace(`{${"id"}}`, encodeURIComponent(String(id))); | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
|             let baseOptions; | ||||
|             if (configuration) { | ||||
|                 baseOptions = configuration.baseOptions; | ||||
|             } | ||||
| 
 | ||||
|             const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
| 
 | ||||
|             // authentication cookie required
 | ||||
| 
 | ||||
|             // authentication api_key required
 | ||||
|             await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) | ||||
| 
 | ||||
|             // authentication bearer required
 | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonStatisticsResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
| @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat | ||||
|         getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { | ||||
|             return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<PersonStatisticsResponseDto> { | ||||
|             return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. | ||||
| @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { | ||||
|     readonly id: string | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for getPersonStatistics operation in PersonApi. | ||||
|  * @export | ||||
|  * @interface PersonApiGetPersonStatisticsRequest | ||||
|  */ | ||||
| export interface PersonApiGetPersonStatisticsRequest { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof PersonApiGetPersonStatistics | ||||
|      */ | ||||
|     readonly id: string | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for getPersonThumbnail operation in PersonApi. | ||||
|  * @export | ||||
| @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { | ||||
|         return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof PersonApi | ||||
|      */ | ||||
|     public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { | ||||
|         return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. | ||||
| @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} name  | ||||
|          * @param {boolean} [withHidden]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'name' is not null or undefined
 | ||||
|             assertParamExists('searchPerson', 'name', name) | ||||
|             const localVarPath = `/search/person`; | ||||
| @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|                 localVarQueryParameter['name'] = name; | ||||
|             } | ||||
| 
 | ||||
|             if (withHidden !== undefined) { | ||||
|                 localVarQueryParameter['withHidden'] = withHidden; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
| @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} name  | ||||
|          * @param {boolean} [withHidden]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); | ||||
|         async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|     } | ||||
| @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { | ||||
|             return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
| @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { | ||||
|      * @memberof SearchApiSearchPerson | ||||
|      */ | ||||
|     readonly name: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof SearchApiSearchPerson | ||||
|      */ | ||||
|     readonly withHidden?: boolean | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { | ||||
|      * @memberof SearchApi | ||||
|      */ | ||||
|     public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { | ||||
|         return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							| @@ -96,6 +96,7 @@ doc/PeopleUpdateDto.md | ||||
| doc/PeopleUpdateItem.md | ||||
| doc/PersonApi.md | ||||
| doc/PersonResponseDto.md | ||||
| doc/PersonStatisticsResponseDto.md | ||||
| doc/PersonUpdateDto.md | ||||
| doc/QueueStatusDto.md | ||||
| doc/RecognitionConfig.md | ||||
| @@ -269,6 +270,7 @@ lib/model/people_response_dto.dart | ||||
| lib/model/people_update_dto.dart | ||||
| lib/model/people_update_item.dart | ||||
| lib/model/person_response_dto.dart | ||||
| lib/model/person_statistics_response_dto.dart | ||||
| lib/model/person_update_dto.dart | ||||
| lib/model/queue_status_dto.dart | ||||
| lib/model/recognition_config.dart | ||||
| @@ -421,6 +423,7 @@ test/people_update_dto_test.dart | ||||
| test/people_update_item_test.dart | ||||
| test/person_api_test.dart | ||||
| test/person_response_dto_test.dart | ||||
| test/person_statistics_response_dto_test.dart | ||||
| test/person_update_dto_test.dart | ||||
| test/queue_status_dto_test.dart | ||||
| test/recognition_config_test.dart | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -152,6 +152,7 @@ Class | Method | HTTP request | Description | ||||
| *PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person |  | ||||
| *PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} |  | ||||
| *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |  | ||||
| *PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |  | ||||
| *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |  | ||||
| *PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge |  | ||||
| *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |  | ||||
| @@ -283,6 +284,7 @@ Class | Method | HTTP request | Description | ||||
|  - [PeopleUpdateDto](doc//PeopleUpdateDto.md) | ||||
|  - [PeopleUpdateItem](doc//PeopleUpdateItem.md) | ||||
|  - [PersonResponseDto](doc//PersonResponseDto.md) | ||||
|  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) | ||||
|  - [PersonUpdateDto](doc//PersonUpdateDto.md) | ||||
|  - [QueueStatusDto](doc//QueueStatusDto.md) | ||||
|  - [RecognitionConfig](doc//RecognitionConfig.md) | ||||
|   | ||||
							
								
								
									
										56
									
								
								mobile/openapi/doc/PersonApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										56
									
								
								mobile/openapi/doc/PersonApi.md
									
									
									
										generated
									
									
									
								
							| @@ -12,6 +12,7 @@ Method | HTTP request | Description | ||||
| [**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person |  | ||||
| [**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} |  | ||||
| [**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |  | ||||
| [**getPersonStatistics**](PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |  | ||||
| [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |  | ||||
| [**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge |  | ||||
| [**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person |  | ||||
| @@ -183,6 +184,61 @@ 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) | ||||
| 
 | ||||
| # **getPersonStatistics** | ||||
| > PersonStatisticsResponseDto getPersonStatistics(id) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| // TODO Configure API key authorization: cookie | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure API key authorization: api_key | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure HTTP Bearer authorization: bearer | ||||
| // Case 1. Use String Token | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||
| // Case 2. Use Function which generate token. | ||||
| // String yourTokenGeneratorFunction() { ... } | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||
| 
 | ||||
| final api_instance = PersonApi(); | ||||
| final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getPersonStatistics(id); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling PersonApi->getPersonStatistics: $e\n'); | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ### Parameters | ||||
| 
 | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **id** | **String**|  |  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
| [**PersonStatisticsResponseDto**](PersonStatisticsResponseDto.md) | ||||
| 
 | ||||
| ### Authorization | ||||
| 
 | ||||
| [cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) | ||||
| 
 | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: Not defined | ||||
|  - **Accept**: application/json | ||||
| 
 | ||||
| [[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) | ||||
| 
 | ||||
| # **getPersonThumbnail** | ||||
| > MultipartFile getPersonThumbnail(id) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										15
									
								
								mobile/openapi/doc/PersonStatisticsResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/doc/PersonStatisticsResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| # openapi.model.PersonStatisticsResponseDto | ||||
| 
 | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
| 
 | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **assets** | **int** |  |  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										6
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							| @@ -151,7 +151,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) | ||||
| 
 | ||||
| # **searchPerson** | ||||
| > List<PersonResponseDto> searchPerson(name) | ||||
| > List<PersonResponseDto> searchPerson(name, withHidden) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @@ -175,9 +175,10 @@ import 'package:openapi/api.dart'; | ||||
| 
 | ||||
| final api_instance = SearchApi(); | ||||
| final name = name_example; // String |  | ||||
| final withHidden = true; // bool |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.searchPerson(name); | ||||
|     final result = api_instance.searchPerson(name, withHidden); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling SearchApi->searchPerson: $e\n'); | ||||
| @@ -189,6 +190,7 @@ try { | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **name** | **String**|  |  | ||||
|  **withHidden** | **bool**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -128,6 +128,7 @@ part 'model/people_response_dto.dart'; | ||||
| part 'model/people_update_dto.dart'; | ||||
| part 'model/people_update_item.dart'; | ||||
| part 'model/person_response_dto.dart'; | ||||
| part 'model/person_statistics_response_dto.dart'; | ||||
| part 'model/person_update_dto.dart'; | ||||
| part 'model/queue_status_dto.dart'; | ||||
| part 'model/recognition_config.dart'; | ||||
|   | ||||
							
								
								
									
										48
									
								
								mobile/openapi/lib/api/person_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										48
									
								
								mobile/openapi/lib/api/person_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -166,6 +166,54 @@ class PersonApi { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /person/{id}/statistics' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   Future<Response> getPersonStatisticsWithHttpInfo(String id,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/person/{id}/statistics' | ||||
|       .replaceAll('{id}', id); | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'GET', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   Future<PersonStatisticsResponseDto?> getPersonStatistics(String id,) async { | ||||
|     final response = await getPersonStatisticsWithHttpInfo(id,); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|     // When a remote server returns no body with a status of 204, we shall not decode it. | ||||
|     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||
|     // FormatException when trying to decode an empty string. | ||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { | ||||
|       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonStatisticsResponseDto',) as PersonStatisticsResponseDto; | ||||
|      | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /person/{id}/thumbnail' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   | ||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -220,7 +220,9 @@ class SearchApi { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] name (required): | ||||
|   Future<Response> searchPersonWithHttpInfo(String name,) async { | ||||
|   /// | ||||
|   /// * [bool] withHidden: | ||||
|   Future<Response> searchPersonWithHttpInfo(String name, { bool? withHidden, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/search/person'; | ||||
| 
 | ||||
| @@ -232,6 +234,9 @@ class SearchApi { | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|       queryParams.addAll(_queryParams('', 'name', name)); | ||||
|     if (withHidden != null) { | ||||
|       queryParams.addAll(_queryParams('', 'withHidden', withHidden)); | ||||
|     } | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| @@ -250,8 +255,10 @@ class SearchApi { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] name (required): | ||||
|   Future<List<PersonResponseDto>?> searchPerson(String name,) async { | ||||
|     final response = await searchPersonWithHttpInfo(name,); | ||||
|   /// | ||||
|   /// * [bool] withHidden: | ||||
|   Future<List<PersonResponseDto>?> searchPerson(String name, { bool? withHidden, }) async { | ||||
|     final response = await searchPersonWithHttpInfo(name,  withHidden: withHidden, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -347,6 +347,8 @@ class ApiClient { | ||||
|           return PeopleUpdateItem.fromJson(value); | ||||
|         case 'PersonResponseDto': | ||||
|           return PersonResponseDto.fromJson(value); | ||||
|         case 'PersonStatisticsResponseDto': | ||||
|           return PersonStatisticsResponseDto.fromJson(value); | ||||
|         case 'PersonUpdateDto': | ||||
|           return PersonUpdateDto.fromJson(value); | ||||
|         case 'QueueStatusDto': | ||||
|   | ||||
							
								
								
									
										98
									
								
								mobile/openapi/lib/model/person_statistics_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								mobile/openapi/lib/model/person_statistics_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class PersonStatisticsResponseDto { | ||||
|   /// Returns a new [PersonStatisticsResponseDto] instance. | ||||
|   PersonStatisticsResponseDto({ | ||||
|     required this.assets, | ||||
|   }); | ||||
| 
 | ||||
|   int assets; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is PersonStatisticsResponseDto && | ||||
|      other.assets == assets; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (assets.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'PersonStatisticsResponseDto[assets=$assets]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'assets'] = this.assets; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [PersonStatisticsResponseDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static PersonStatisticsResponseDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return PersonStatisticsResponseDto( | ||||
|         assets: mapValueOfType<int>(json, r'assets')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<PersonStatisticsResponseDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <PersonStatisticsResponseDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = PersonStatisticsResponseDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, PersonStatisticsResponseDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, PersonStatisticsResponseDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = PersonStatisticsResponseDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of PersonStatisticsResponseDto-objects as value to a dart map | ||||
|   static Map<String, List<PersonStatisticsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<PersonStatisticsResponseDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = PersonStatisticsResponseDto.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'assets', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/person_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/person_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -32,6 +32,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<PersonStatisticsResponseDto> getPersonStatistics(String id) async | ||||
|     test('test getPersonStatistics', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<MultipartFile> getPersonThumbnail(String id) async | ||||
|     test('test getPersonThumbnail', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										27
									
								
								mobile/openapi/test/person_statistics_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								mobile/openapi/test/person_statistics_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
| 
 | ||||
| // tests for PersonStatisticsResponseDto | ||||
| void main() { | ||||
|   // final instance = PersonStatisticsResponseDto(); | ||||
| 
 | ||||
|   group('test PersonStatisticsResponseDto', () { | ||||
|     // int assets | ||||
|     test('to test the property `assets`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -27,7 +27,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<PersonResponseDto>> searchPerson(String name) async | ||||
|     //Future<List<PersonResponseDto>> searchPerson(String name, { bool withHidden }) async | ||||
|     test('test searchPerson', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
| @@ -3685,6 +3685,48 @@ | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/person/{id}/statistics": { | ||||
|       "get": { | ||||
|         "operationId": "getPersonStatistics", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "id", | ||||
|             "required": true, | ||||
|             "in": "path", | ||||
|             "schema": { | ||||
|               "format": "uuid", | ||||
|               "type": "string" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/PersonStatisticsResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             }, | ||||
|             "description": "" | ||||
|           } | ||||
|         }, | ||||
|         "security": [ | ||||
|           { | ||||
|             "bearer": [] | ||||
|           }, | ||||
|           { | ||||
|             "cookie": [] | ||||
|           }, | ||||
|           { | ||||
|             "api_key": [] | ||||
|           } | ||||
|         ], | ||||
|         "tags": [ | ||||
|           "Person" | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/person/{id}/thumbnail": { | ||||
|       "get": { | ||||
|         "operationId": "getPersonThumbnail", | ||||
| @@ -3947,6 +3989,14 @@ | ||||
|             "schema": { | ||||
|               "type": "string" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "withHidden", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
| @@ -7401,6 +7451,17 @@ | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "PersonStatisticsResponseDto": { | ||||
|         "properties": { | ||||
|           "assets": { | ||||
|             "type": "integer" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "assets" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "PersonUpdateDto": { | ||||
|         "properties": { | ||||
|           "birthDate": { | ||||
|   | ||||
| @@ -73,6 +73,11 @@ export class PersonResponseDto { | ||||
|   isHidden!: boolean; | ||||
| } | ||||
|  | ||||
| export class PersonStatisticsResponseDto { | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   assets!: number; | ||||
| } | ||||
|  | ||||
| export class PeopleResponseDto { | ||||
|   @ApiProperty({ type: 'integer' }) | ||||
|   total!: number; | ||||
|   | ||||
| @@ -42,6 +42,8 @@ const responseDto: PersonResponseDto = { | ||||
|   isHidden: false, | ||||
| }; | ||||
|  | ||||
| const statistics = { assets: 3 }; | ||||
|  | ||||
| const croppedFace = Buffer.from('Cropped Face'); | ||||
|  | ||||
| const detectFaceMock = { | ||||
| @@ -731,4 +733,21 @@ describe(PersonService.name, () => { | ||||
|       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getStatistics', () => { | ||||
|     it('should get correct number of person', async () => { | ||||
|       personMock.getById.mockResolvedValue(personStub.primaryPerson); | ||||
|       personMock.getStatistics.mockResolvedValue(statistics); | ||||
|       accessMock.person.hasOwnerAccess.mockResolvedValue(true); | ||||
|       await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); | ||||
|       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||
|     }); | ||||
|  | ||||
|     it('should require person.read permission', async () => { | ||||
|       personMock.getById.mockResolvedValue(personStub.primaryPerson); | ||||
|       accessMock.person.hasOwnerAccess.mockResolvedValue(false); | ||||
|       await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -33,6 +33,7 @@ import { | ||||
|   PeopleUpdateDto, | ||||
|   PersonResponseDto, | ||||
|   PersonSearchDto, | ||||
|   PersonStatisticsResponseDto, | ||||
|   PersonUpdateDto, | ||||
|   mapPerson, | ||||
| } from './person.dto'; | ||||
| @@ -84,6 +85,11 @@ export class PersonService { | ||||
|     return this.findOrFail(id).then(mapPerson); | ||||
|   } | ||||
|  | ||||
|   async getStatistics(authUser: AuthUserDto, id: string): Promise<PersonStatisticsResponseDto> { | ||||
|     await this.access.requirePermission(authUser, Permission.PERSON_READ, id); | ||||
|     return this.repository.getStatistics(id); | ||||
|   } | ||||
|  | ||||
|   async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> { | ||||
|     await this.access.requirePermission(authUser, Permission.PERSON_READ, id); | ||||
|     const person = await this.repository.getById(id); | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities'; | ||||
|  | ||||
| export const IPersonRepository = 'IPersonRepository'; | ||||
|  | ||||
| export interface PersonSearchOptions { | ||||
| @@ -6,6 +7,10 @@ export interface PersonSearchOptions { | ||||
|   withHidden: boolean; | ||||
| } | ||||
|  | ||||
| export interface PersonNameSearchOptions { | ||||
|   withHidden?: boolean; | ||||
| } | ||||
|  | ||||
| export interface AssetFaceId { | ||||
|   assetId: string; | ||||
|   personId: string; | ||||
| @@ -16,13 +21,17 @@ export interface UpdateFacesData { | ||||
|   newPersonId: string; | ||||
| } | ||||
|  | ||||
| export interface PersonStatistics { | ||||
|   assets: number; | ||||
| } | ||||
|  | ||||
| export interface IPersonRepository { | ||||
|   getAll(): Promise<PersonEntity[]>; | ||||
|   getAllWithoutThumbnail(): Promise<PersonEntity[]>; | ||||
|   getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>; | ||||
|   getAllWithoutFaces(): Promise<PersonEntity[]>; | ||||
|   getById(personId: string): Promise<PersonEntity | null>; | ||||
|   getByName(userId: string, personName: string): Promise<PersonEntity[]>; | ||||
|   getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>; | ||||
|  | ||||
|   getAssets(personId: string): Promise<AssetEntity[]>; | ||||
|   prepareReassignFaces(data: UpdateFacesData): Promise<string[]>; | ||||
| @@ -33,6 +42,8 @@ export interface IPersonRepository { | ||||
|   delete(entity: PersonEntity): Promise<PersonEntity | null>; | ||||
|   deleteAll(): Promise<number>; | ||||
|  | ||||
|   getStatistics(personId: string): Promise<PersonStatistics>; | ||||
|  | ||||
|   getAllFaces(): Promise<AssetFaceEntity[]>; | ||||
|   getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>; | ||||
|   getRandomFace(personId: string): Promise<AssetFaceEntity | null>; | ||||
|   | ||||
| @@ -90,4 +90,9 @@ export class SearchPeopleDto { | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   name!: string; | ||||
|  | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   @Optional() | ||||
|   withHidden?: boolean; | ||||
| } | ||||
|   | ||||
| @@ -159,8 +159,8 @@ export class SearchService { | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> { | ||||
|     return await this.personRepository.getByName(authUser.id, dto.name); | ||||
|   searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> { | ||||
|     return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden }); | ||||
|   } | ||||
|  | ||||
|   async handleIndexAlbums() { | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import { | ||||
|   PersonResponseDto, | ||||
|   PersonSearchDto, | ||||
|   PersonService, | ||||
|   PersonStatisticsResponseDto, | ||||
|   PersonUpdateDto, | ||||
| } from '@app/domain'; | ||||
| import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; | ||||
| @@ -52,6 +53,14 @@ export class PersonController { | ||||
|     return this.service.update(authUser, id, dto); | ||||
|   } | ||||
|  | ||||
|   @Get(':id/statistics') | ||||
|   getPersonStatistics( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|     @Param() { id }: UUIDParamDto, | ||||
|   ): Promise<PersonStatisticsResponseDto> { | ||||
|     return this.service.getStatistics(authUser, id); | ||||
|   } | ||||
|  | ||||
|   @Get(':id/thumbnail') | ||||
|   @ApiOkResponse({ | ||||
|     content: { | ||||
|   | ||||
| @@ -1,4 +1,11 @@ | ||||
| import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain'; | ||||
| import { | ||||
|   AssetFaceId, | ||||
|   IPersonRepository, | ||||
|   PersonNameSearchOptions, | ||||
|   PersonSearchOptions, | ||||
|   PersonStatistics, | ||||
|   UpdateFacesData, | ||||
| } from '@app/domain'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { In, Repository } from 'typeorm'; | ||||
| import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; | ||||
| @@ -96,14 +103,37 @@ export class PersonRepository implements IPersonRepository { | ||||
|     return this.personRepository.findOne({ where: { id: personId } }); | ||||
|   } | ||||
|  | ||||
|   getByName(userId: string, personName: string): Promise<PersonEntity[]> { | ||||
|     return this.personRepository | ||||
|   getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> { | ||||
|     const queryBuilder = this.personRepository | ||||
|       .createQueryBuilder('person') | ||||
|       .leftJoin('person.faces', 'face') | ||||
|       .where('person.ownerId = :userId', { userId }) | ||||
|       .andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` }) | ||||
|       .limit(20) | ||||
|       .getMany(); | ||||
|       .andWhere('LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere', { | ||||
|         nameStart: `${personName.toLowerCase()}%`, | ||||
|         nameAnywhere: `% ${personName.toLowerCase()}%`, | ||||
|       }) | ||||
|       .groupBy('person.id') | ||||
|       .orderBy('COUNT(face.assetId)', 'DESC') | ||||
|       .limit(20); | ||||
|  | ||||
|     if (!withHidden) { | ||||
|       queryBuilder.andWhere('person.isHidden = false'); | ||||
|     } | ||||
|     return queryBuilder.getMany(); | ||||
|   } | ||||
|  | ||||
|   async getStatistics(personId: string): Promise<PersonStatistics> { | ||||
|     return { | ||||
|       assets: await this.assetFaceRepository | ||||
|         .createQueryBuilder('face') | ||||
|         .leftJoin('face.asset', 'asset') | ||||
|         .where('face.personId = :personId', { personId }) | ||||
|         .andWhere('asset.isArchived = false') | ||||
|         .andWhere('asset.deletedAt IS NULL') | ||||
|         .andWhere('asset.livePhotoVideoId IS NULL') | ||||
|         .distinct(true) | ||||
|         .getCount(), | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   getAssets(personId: string): Promise<AssetEntity[]> { | ||||
|   | ||||
| @@ -16,6 +16,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => { | ||||
|     deleteAll: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|  | ||||
|     getStatistics: jest.fn(), | ||||
|     getAllFaces: jest.fn(), | ||||
|     getFacesByIds: jest.fn(), | ||||
|     getRandomFace: jest.fn(), | ||||
|   | ||||
							
								
								
									
										122
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										122
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { | ||||
|      */ | ||||
|     'thumbnailPath': string; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface PersonStatisticsResponseDto | ||||
|  */ | ||||
| export interface PersonStatisticsResponseDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @memberof PersonStatisticsResponseDto | ||||
|      */ | ||||
|     'assets': number; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
| @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| 
 | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'id' is not null or undefined
 | ||||
|             assertParamExists('getPersonStatistics', 'id', id) | ||||
|             const localVarPath = `/person/{id}/statistics` | ||||
|                 .replace(`{${"id"}}`, encodeURIComponent(String(id))); | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
|             let baseOptions; | ||||
|             if (configuration) { | ||||
|                 baseOptions = configuration.baseOptions; | ||||
|             } | ||||
| 
 | ||||
|             const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
| 
 | ||||
|             // authentication cookie required
 | ||||
| 
 | ||||
|             // authentication api_key required
 | ||||
|             await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) | ||||
| 
 | ||||
|             // authentication bearer required
 | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonStatisticsResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} id  | ||||
| @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat | ||||
|         getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { | ||||
|             return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<PersonStatisticsResponseDto> { | ||||
|             return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. | ||||
| @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { | ||||
|     readonly id: string | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for getPersonStatistics operation in PersonApi. | ||||
|  * @export | ||||
|  * @interface PersonApiGetPersonStatisticsRequest | ||||
|  */ | ||||
| export interface PersonApiGetPersonStatisticsRequest { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof PersonApiGetPersonStatistics | ||||
|      */ | ||||
|     readonly id: string | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for getPersonThumbnail operation in PersonApi. | ||||
|  * @export | ||||
| @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { | ||||
|         return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof PersonApi | ||||
|      */ | ||||
|     public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { | ||||
|         return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. | ||||
| @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} name  | ||||
|          * @param {boolean} [withHidden]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'name' is not null or undefined
 | ||||
|             assertParamExists('searchPerson', 'name', name) | ||||
|             const localVarPath = `/search/person`; | ||||
| @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|                 localVarQueryParameter['name'] = name; | ||||
|             } | ||||
| 
 | ||||
|             if (withHidden !== undefined) { | ||||
|                 localVarQueryParameter['withHidden'] = withHidden; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
| @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {string} name  | ||||
|          * @param {boolean} [withHidden]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); | ||||
|         async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|     } | ||||
| @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { | ||||
|             return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); | ||||
|             return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
| @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { | ||||
|      * @memberof SearchApiSearchPerson | ||||
|      */ | ||||
|     readonly name: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof SearchApiSearchPerson | ||||
|      */ | ||||
|     readonly withHidden?: boolean | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { | ||||
|      * @memberof SearchApi | ||||
|      */ | ||||
|     public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { | ||||
|         return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   | ||||
| @@ -11,12 +11,13 @@ | ||||
|   const dispatch = createEventDispatcher<{ | ||||
|     change: string; | ||||
|     cancel: void; | ||||
|     input: void; | ||||
|   }>(); | ||||
| </script> | ||||
|  | ||||
| <div | ||||
|   class="flex w-full place-items-center {suggestedPeople | ||||
|     ? 'rounded-t-lg border-b dark:border-immich-dark-gray' | ||||
|   class="flex w-full h-14 place-items-center {suggestedPeople | ||||
|     ? 'rounded-t-lg dark:border-immich-dark-gray' | ||||
|     : 'rounded-lg'}  bg-gray-100 p-2 dark:bg-gray-700" | ||||
| > | ||||
|   <ImageThumbnail | ||||
| @@ -39,6 +40,7 @@ | ||||
|       type="text" | ||||
|       placeholder="New name or nickname" | ||||
|       bind:value={name} | ||||
|       on:input={() => dispatch('input')} | ||||
|     /> | ||||
|     <Button size="sm" type="submit">Done</Button> | ||||
|   </form> | ||||
|   | ||||
| @@ -258,7 +258,7 @@ | ||||
|       changeName(); | ||||
|       return; | ||||
|     } | ||||
|     const { data } = await api.searchApi.searchPerson({ name: personName }); | ||||
|     const { data } = await api.searchApi.searchPerson({ name: personName, withHidden: true }); | ||||
|  | ||||
|     // We check if another person has the same name as the name entered by the user | ||||
|  | ||||
|   | ||||
| @@ -9,10 +9,12 @@ export const load = (async ({ locals, parent, params }) => { | ||||
|   } | ||||
|  | ||||
|   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId }); | ||||
|   const { data: statistics } = await locals.api.personApi.getPersonStatistics({ id: params.personId }); | ||||
|  | ||||
|   return { | ||||
|     user, | ||||
|     person, | ||||
|     statistics, | ||||
|     meta: { | ||||
|       title: person.name || 'Person', | ||||
|     }, | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script lang="ts"> | ||||
|   import { afterNavigate, goto, invalidateAll } from '$app/navigation'; | ||||
|   import { afterNavigate, goto } from '$app/navigation'; | ||||
|   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'; | ||||
| @@ -35,11 +35,11 @@ | ||||
|   import type { PageData } from './$types'; | ||||
|   import { clickOutside } from '$lib/utils/click-outside'; | ||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||
|   import { browser } from '$app/environment'; | ||||
|   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|  | ||||
|   let numberOfAssets = data.statistics.assets; | ||||
|   let { isViewing: showAssetViewer } = assetViewingStore; | ||||
|  | ||||
|   enum ViewMode { | ||||
| @@ -63,7 +63,7 @@ | ||||
|   let isEditingName = false; | ||||
|   let previousRoute: string = AppRoute.EXPLORE; | ||||
|   let previousPersonId: string = data.person.id; | ||||
|   let people: PersonResponseDto[]; | ||||
|   let people: PersonResponseDto[] = []; | ||||
|   let personMerge1: PersonResponseDto; | ||||
|   let personMerge2: PersonResponseDto; | ||||
|   let potentialMergePeople: PersonResponseDto[] = []; | ||||
| @@ -84,34 +84,27 @@ | ||||
|    * or if the new search word starts with another word / letter | ||||
|    **/ | ||||
|   let searchWord: string; | ||||
|   let maxPeople = false; | ||||
|   let isSearchingPeople = false; | ||||
|  | ||||
|   const searchPeople = async () => { | ||||
|     isSearchingPeople = true; | ||||
|     people = []; | ||||
|     if ((people.length < 20 && name.startsWith(searchWord)) || name === '') { | ||||
|       return; | ||||
|     } | ||||
|     const timeout = setTimeout(() => (isSearchingPeople = true), 300); | ||||
|     try { | ||||
|       const { data } = await api.searchApi.searchPerson({ name }); | ||||
|       people = data; | ||||
|       searchWord = name; | ||||
|       if (data.length < 20) { | ||||
|         maxPeople = false; | ||||
|       } else { | ||||
|         maxPeople = true; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       people = []; | ||||
|       handleError(error, "Can't search people"); | ||||
|     } finally { | ||||
|       clearTimeout(timeout); | ||||
|     } | ||||
|  | ||||
|     isSearchingPeople = false; | ||||
|   }; | ||||
|  | ||||
|   $: { | ||||
|     if (name !== '' && browser) { | ||||
|       if (maxPeople === true || (!name.startsWith(searchWord) && maxPeople === false)) searchPeople(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); | ||||
|   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); | ||||
|   $: $onPersonThumbnail === data.person.id && | ||||
| @@ -122,10 +115,13 @@ | ||||
|       suggestedPeople = !name | ||||
|         ? [] | ||||
|         : people | ||||
|             .filter( | ||||
|               (person: PersonResponseDto) => | ||||
|                 person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id, | ||||
|             ) | ||||
|             .filter((person: PersonResponseDto) => { | ||||
|               const nameParts = person.name.split(' '); | ||||
|               return ( | ||||
|                 nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) && | ||||
|                 person.id !== data.person.id | ||||
|               ); | ||||
|             }) | ||||
|             .slice(0, 5); | ||||
|     } | ||||
|   } | ||||
| @@ -204,6 +200,17 @@ | ||||
|     viewMode = ViewMode.VIEW_ASSETS; | ||||
|   }; | ||||
|  | ||||
|   const updateAssetCount = async () => { | ||||
|     try { | ||||
|       const { data: statistics } = await api.personApi.getPersonStatistics({ | ||||
|         id: data.person.id, | ||||
|       }); | ||||
|       numberOfAssets = statistics.assets; | ||||
|     } catch (error) { | ||||
|       handleError(error, "Can't update the asset count"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { | ||||
|     const [personToMerge, personToBeMergedIn] = response; | ||||
|     viewMode = ViewMode.VIEW_ASSETS; | ||||
| @@ -219,8 +226,8 @@ | ||||
|       }); | ||||
|       people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); | ||||
|       if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) { | ||||
|         changeName(); | ||||
|         invalidateAll(); | ||||
|         await updateAssetCount(); | ||||
|         refreshAssetGrid = !refreshAssetGrid; | ||||
|         return; | ||||
|       } | ||||
|       goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true }); | ||||
| @@ -232,6 +239,7 @@ | ||||
|   const handleSuggestPeople = (person: PersonResponseDto) => { | ||||
|     isEditingName = false; | ||||
|     potentialMergePeople = []; | ||||
|     personName = person.name; | ||||
|     personMerge1 = data.person; | ||||
|     personMerge2 = person; | ||||
|     viewMode = ViewMode.SUGGEST_MERGE; | ||||
| @@ -266,6 +274,7 @@ | ||||
|   }; | ||||
|  | ||||
|   const handleNameChange = async (name: string) => { | ||||
|     isEditingName = false; | ||||
|     potentialMergePeople = []; | ||||
|     personName = name; | ||||
|  | ||||
| @@ -277,7 +286,7 @@ | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const result = await api.searchApi.searchPerson({ name: personName }); | ||||
|     const result = await api.searchApi.searchPerson({ name: personName, withHidden: true }); | ||||
|  | ||||
|     const existingPerson = result.data.find( | ||||
|       (person: PersonResponseDto) => | ||||
| @@ -413,42 +422,49 @@ | ||||
|           on:outclick={handleCancelEditName} | ||||
|           on:escape={handleCancelEditName} | ||||
|         > | ||||
|           <section class="flex w-96 place-items-center border-black"> | ||||
|           <section class="flex w-64 sm:w-96 place-items-center border-black"> | ||||
|             {#if isEditingName} | ||||
|               <EditNameInput | ||||
|                 person={data.person} | ||||
|                 suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople} | ||||
|                 bind:name | ||||
|                 on:change={(event) => handleNameChange(event.detail)} | ||||
|                 on:input={searchPeople} | ||||
|               /> | ||||
|             {:else} | ||||
|               <button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}> | ||||
|                 <ImageThumbnail | ||||
|                   circle | ||||
|                   shadow | ||||
|                   url={thumbnailData} | ||||
|                   altText={data.person.name} | ||||
|                   widthStyle="3.375rem" | ||||
|                   heightStyle="3.375rem" | ||||
|                 /> | ||||
|               </button> | ||||
|  | ||||
|               <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> | ||||
|               <div class="relative"> | ||||
|                 <button | ||||
|                   class="flex items-center justify-center" | ||||
|                   title="Edit name" | ||||
|                   on:click={() => (isEditingName = true)} | ||||
|                 > | ||||
|                   <ImageThumbnail | ||||
|                     circle | ||||
|                     shadow | ||||
|                     url={thumbnailData} | ||||
|                     altText={data.person.name} | ||||
|                     widthStyle="3.375rem" | ||||
|                     heightStyle="3.375rem" | ||||
|                   /> | ||||
|                   <div | ||||
|                     class="flex flex-col justify-center text-left px-4 h-14 text-immich-primary dark:text-immich-dark-primary" | ||||
|                   > | ||||
|                     {#if data.person.name} | ||||
|                       <p class="w-40 sm:w-72 font-medium truncate">{data.person.name}</p> | ||||
|                       <p class="absolute w-fit text-sm text-gray-500 dark:text-immich-gray bottom-0"> | ||||
|                         {`${numberOfAssets} asset${numberOfAssets > 1 ? 's' : ''}`} | ||||
|                       </p> | ||||
|                     {:else} | ||||
|                       <p class="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} | ||||
|                   </div> | ||||
|                 </button> | ||||
|               </div> | ||||
|             {/if} | ||||
|           </section> | ||||
|           {#if isEditingName} | ||||
|             <div class="absolute z-[999] w-96"> | ||||
|             <div class="absolute z-[999] w-64 sm:w-96"> | ||||
|               {#if isSearchingPeople} | ||||
|                 <div | ||||
|                   class="flex rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700" | ||||
| @@ -460,9 +476,8 @@ | ||||
|               {:else} | ||||
|                 {#each suggestedPeople as person, index (person.id)} | ||||
|                   <div | ||||
|                     class="flex {index === suggestedPeople.length - 1 | ||||
|                       ? 'rounded-b-lg' | ||||
|                       : 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700" | ||||
|                     class="flex border-t dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700 {index === | ||||
|                       suggestedPeople.length - 1 && 'rounded-b-lg'}" | ||||
|                   > | ||||
|                     <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}> | ||||
|                       <ImageThumbnail | ||||
|   | ||||
		Reference in New Issue
	
	Block a user