mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server,web): improve performances in person page (1) (#4387)
* feat: improve performances in people page * feat: add loadingspinner when searching * fix: reset people on error * fix: case insensitive * feat: better sql query * fix: reset people list before api request * fix: format
This commit is contained in:
		
							
								
								
									
										89
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										89
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            // verify required parameter 'name' is not null or undefined
 | 
			
		||||
            assertParamExists('searchPerson', 'name', name)
 | 
			
		||||
            const localVarPath = `/search/person`;
 | 
			
		||||
            // 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)
 | 
			
		||||
 | 
			
		||||
            if (name !== undefined) {
 | 
			
		||||
                localVarQueryParameter['name'] = name;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
@@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {string} name 
 | 
			
		||||
         * @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);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
 | 
			
		||||
        search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
 | 
			
		||||
            return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
 | 
			
		||||
            return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
 | 
			
		||||
    readonly motion?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Request parameters for searchPerson operation in SearchApi.
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface SearchApiSearchPersonRequest
 | 
			
		||||
 */
 | 
			
		||||
export interface SearchApiSearchPersonRequest {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof SearchApiSearchPerson
 | 
			
		||||
     */
 | 
			
		||||
    readonly name: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * SearchApi - object-oriented interface
 | 
			
		||||
 * @export
 | 
			
		||||
@@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
 | 
			
		||||
    public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof SearchApi
 | 
			
		||||
     */
 | 
			
		||||
    public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@@ -154,6 +154,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 | 
			
		||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | 
 | 
			
		||||
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | 
 | 
			
		||||
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | 
 | 
			
		||||
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										56
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										56
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							@@ -11,6 +11,7 @@ Method | HTTP request | Description
 | 
			
		||||
------------- | ------------- | -------------
 | 
			
		||||
[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
[**search**](SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# **getExploreData**
 | 
			
		||||
@@ -149,3 +150,58 @@ 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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### 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 = SearchApi();
 | 
			
		||||
final name = name_example; // String | 
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.searchPerson(name);
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling SearchApi->searchPerson: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
 | 
			
		||||
Name | Type | Description  | Notes
 | 
			
		||||
------------- | ------------- | ------------- | -------------
 | 
			
		||||
 **name** | **String**|  | 
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
[**List<PersonResponseDto>**](PersonResponseDto.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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										52
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										52
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							@@ -215,4 +215,56 @@ class SearchApi {
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'GET /search/person' operation and returns the [Response].
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] name (required):
 | 
			
		||||
  Future<Response> searchPersonWithHttpInfo(String name,) async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/search/person';
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
      queryParams.addAll(_queryParams('', 'name', name));
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      path,
 | 
			
		||||
      'GET',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Parameters:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [String] name (required):
 | 
			
		||||
  Future<List<PersonResponseDto>?> searchPerson(String name,) async {
 | 
			
		||||
    final response = await searchPersonWithHttpInfo(name,);
 | 
			
		||||
    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) {
 | 
			
		||||
      final responseBody = await _decodeBodyBytes(response);
 | 
			
		||||
      return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
 | 
			
		||||
        .cast<PersonResponseDto>()
 | 
			
		||||
        .toList();
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							@@ -27,5 +27,10 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<List<PersonResponseDto>> searchPerson(String name) async
 | 
			
		||||
    test('test searchPerson', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3789,6 +3789,50 @@
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/search/person": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "searchPerson",
 | 
			
		||||
        "parameters": [
 | 
			
		||||
          {
 | 
			
		||||
            "name": "name",
 | 
			
		||||
            "required": true,
 | 
			
		||||
            "in": "query",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "type": "string"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "content": {
 | 
			
		||||
              "application/json": {
 | 
			
		||||
                "schema": {
 | 
			
		||||
                  "items": {
 | 
			
		||||
                    "$ref": "#/components/schemas/PersonResponseDto"
 | 
			
		||||
                  },
 | 
			
		||||
                  "type": "array"
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            "description": ""
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "api_key": []
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Search"
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/server-info": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getServerInfo",
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ export interface IPersonRepository {
 | 
			
		||||
  getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
 | 
			
		||||
  getAllWithoutFaces(): Promise<PersonEntity[]>;
 | 
			
		||||
  getById(personId: string): Promise<PersonEntity | null>;
 | 
			
		||||
  getByName(userId: string, personName: string): Promise<PersonEntity[]>;
 | 
			
		||||
 | 
			
		||||
  getAssets(personId: string): Promise<AssetEntity[]>;
 | 
			
		||||
  prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
 | 
			
		||||
 
 | 
			
		||||
@@ -85,3 +85,9 @@ export class SearchDto {
 | 
			
		||||
  @Transform(toBoolean)
 | 
			
		||||
  motion?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SearchPeopleDto {
 | 
			
		||||
  @IsString()
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  name!: string;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import { AssetResponseDto, mapAsset } from '../asset';
 | 
			
		||||
import { AuthUserDto } from '../auth';
 | 
			
		||||
import { usePagination } from '../domain.util';
 | 
			
		||||
import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
 | 
			
		||||
import { PersonResponseDto } from '../person/person.dto';
 | 
			
		||||
import {
 | 
			
		||||
  AssetFaceId,
 | 
			
		||||
  IAlbumRepository,
 | 
			
		||||
@@ -21,7 +22,7 @@ import {
 | 
			
		||||
  SearchStrategy,
 | 
			
		||||
} from '../repositories';
 | 
			
		||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
 | 
			
		||||
import { SearchDto } from './dto';
 | 
			
		||||
import { SearchDto, SearchPeopleDto } from './dto';
 | 
			
		||||
import { SearchResponseDto } from './response-dto';
 | 
			
		||||
 | 
			
		||||
interface SyncQueue {
 | 
			
		||||
@@ -158,6 +159,10 @@ export class SearchService {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
 | 
			
		||||
    return await this.personRepository.getByName(authUser.id, dto.name);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async handleIndexAlbums() {
 | 
			
		||||
    if (!this.enabled) {
 | 
			
		||||
      return false;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,12 @@
 | 
			
		||||
import { AuthUserDto, SearchDto, SearchExploreResponseDto, SearchResponseDto, SearchService } from '@app/domain';
 | 
			
		||||
import {
 | 
			
		||||
  AuthUserDto,
 | 
			
		||||
  PersonResponseDto,
 | 
			
		||||
  SearchDto,
 | 
			
		||||
  SearchExploreResponseDto,
 | 
			
		||||
  SearchPeopleDto,
 | 
			
		||||
  SearchResponseDto,
 | 
			
		||||
  SearchService,
 | 
			
		||||
} from '@app/domain';
 | 
			
		||||
import { Controller, Get, Query } from '@nestjs/common';
 | 
			
		||||
import { ApiTags } from '@nestjs/swagger';
 | 
			
		||||
import { AuthUser, Authenticated } from '../app.guard';
 | 
			
		||||
@@ -11,6 +19,11 @@ import { UseValidation } from '../app.utils';
 | 
			
		||||
export class SearchController {
 | 
			
		||||
  constructor(private service: SearchService) {}
 | 
			
		||||
 | 
			
		||||
  @Get('person')
 | 
			
		||||
  searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
 | 
			
		||||
    return this.service.searchPerson(authUser, dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get()
 | 
			
		||||
  search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
 | 
			
		||||
    return this.service.search(authUser, dto);
 | 
			
		||||
 
 | 
			
		||||
@@ -95,6 +95,16 @@ export class PersonRepository implements IPersonRepository {
 | 
			
		||||
    return this.personRepository.findOne({ where: { id: personId } });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getByName(userId: string, personName: string): Promise<PersonEntity[]> {
 | 
			
		||||
    return 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();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getAssets(personId: string): Promise<AssetEntity[]> {
 | 
			
		||||
    return this.assetRepository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,8 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
 | 
			
		||||
    getAssets: jest.fn(),
 | 
			
		||||
    getAllWithoutFaces: jest.fn(),
 | 
			
		||||
 | 
			
		||||
    getByName: jest.fn(),
 | 
			
		||||
 | 
			
		||||
    create: jest.fn(),
 | 
			
		||||
    update: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										89
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										89
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							@@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name 
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            // verify required parameter 'name' is not null or undefined
 | 
			
		||||
            assertParamExists('searchPerson', 'name', name)
 | 
			
		||||
            const localVarPath = `/search/person`;
 | 
			
		||||
            // 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)
 | 
			
		||||
 | 
			
		||||
            if (name !== undefined) {
 | 
			
		||||
                localVarQueryParameter['name'] = name;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
            setSearchParams(localVarUrlObj, localVarQueryParameter);
 | 
			
		||||
            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
 | 
			
		||||
            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
 | 
			
		||||
@@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
 | 
			
		||||
            const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {string} name 
 | 
			
		||||
         * @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);
 | 
			
		||||
            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
 | 
			
		||||
        search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
 | 
			
		||||
            return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * 
 | 
			
		||||
         * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
 | 
			
		||||
         * @param {*} [options] Override http request option.
 | 
			
		||||
         * @throws {RequiredError}
 | 
			
		||||
         */
 | 
			
		||||
        searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
 | 
			
		||||
            return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
 | 
			
		||||
    readonly motion?: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Request parameters for searchPerson operation in SearchApi.
 | 
			
		||||
 * @export
 | 
			
		||||
 * @interface SearchApiSearchPersonRequest
 | 
			
		||||
 */
 | 
			
		||||
export interface SearchApiSearchPersonRequest {
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @type {string}
 | 
			
		||||
     * @memberof SearchApiSearchPerson
 | 
			
		||||
     */
 | 
			
		||||
    readonly name: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * SearchApi - object-oriented interface
 | 
			
		||||
 * @export
 | 
			
		||||
@@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
 | 
			
		||||
    public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
 | 
			
		||||
     * @param {*} [options] Override http request option.
 | 
			
		||||
     * @throws {RequiredError}
 | 
			
		||||
     * @memberof SearchApi
 | 
			
		||||
     */
 | 
			
		||||
    public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
 | 
			
		||||
        return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { createEventDispatcher } from 'svelte';
 | 
			
		||||
  import { createEventDispatcher, onMount } from 'svelte';
 | 
			
		||||
  import { api, type PersonResponseDto } from '@api';
 | 
			
		||||
  import FaceThumbnail from './face-thumbnail.svelte';
 | 
			
		||||
  import { quintOut } from 'svelte/easing';
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
  import SwapHorizontal from 'svelte-material-icons/SwapHorizontal.svelte';
 | 
			
		||||
 | 
			
		||||
  export let person: PersonResponseDto;
 | 
			
		||||
  export let people: PersonResponseDto[];
 | 
			
		||||
  let people: PersonResponseDto[];
 | 
			
		||||
  let selectedPeople: PersonResponseDto[] = [];
 | 
			
		||||
  let screenHeight: number;
 | 
			
		||||
  let isShowConfirmation = false;
 | 
			
		||||
@@ -28,6 +28,11 @@
 | 
			
		||||
    (source) => !selectedPeople.some((selected) => selected.id === source.id) && source.id !== person.id,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const { data } = await api.personApi.getAllPeople({ withHidden: false });
 | 
			
		||||
    people = data.people;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const onClose = () => {
 | 
			
		||||
    dispatch('go-back');
 | 
			
		||||
  };
 | 
			
		||||
 
 | 
			
		||||
@@ -254,9 +254,15 @@
 | 
			
		||||
    if (!edittingPerson || personName === edittingPerson.name) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (personName === '') {
 | 
			
		||||
      changeName();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const { data } = await api.searchApi.searchPerson({ name: personName });
 | 
			
		||||
 | 
			
		||||
    // We check if another person has the same name as the name entered by the user
 | 
			
		||||
 | 
			
		||||
    const existingPerson = people.find(
 | 
			
		||||
    const existingPerson = data.find(
 | 
			
		||||
      (person: PersonResponseDto) =>
 | 
			
		||||
        person.name.toLowerCase() === personName.toLowerCase() &&
 | 
			
		||||
        edittingPerson &&
 | 
			
		||||
 
 | 
			
		||||
@@ -9,12 +9,10 @@ export const load = (async ({ locals, parent, params }) => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
 | 
			
		||||
  const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: false });
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    user,
 | 
			
		||||
    person,
 | 
			
		||||
    people,
 | 
			
		||||
    meta: {
 | 
			
		||||
      title: person.name || 'Person',
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
@@ -35,6 +35,8 @@
 | 
			
		||||
  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;
 | 
			
		||||
 | 
			
		||||
@@ -61,7 +63,7 @@
 | 
			
		||||
  let isEditingName = false;
 | 
			
		||||
  let previousRoute: string = AppRoute.EXPLORE;
 | 
			
		||||
  let previousPersonId: string = data.person.id;
 | 
			
		||||
  let people = data.people.people;
 | 
			
		||||
  let people: PersonResponseDto[];
 | 
			
		||||
  let personMerge1: PersonResponseDto;
 | 
			
		||||
  let personMerge2: PersonResponseDto;
 | 
			
		||||
  let potentialMergePeople: PersonResponseDto[] = [];
 | 
			
		||||
@@ -74,20 +76,58 @@
 | 
			
		||||
  let name: string = data.person.name;
 | 
			
		||||
  let suggestedPeople: PersonResponseDto[] = [];
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Save the word used to search people name: for example,
 | 
			
		||||
   * if searching 'r' and the server returns 15 people with names starting with 'r',
 | 
			
		||||
   * there's no need to search again people with name starting with 'ri'.
 | 
			
		||||
   * However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server).
 | 
			
		||||
   * 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 = [];
 | 
			
		||||
    try {
 | 
			
		||||
      const { data } = await api.searchApi.searchPerson({ name });
 | 
			
		||||
      people = data;
 | 
			
		||||
      searchWord = name;
 | 
			
		||||
      if (data.length < 20) {
 | 
			
		||||
        maxPeople = false;
 | 
			
		||||
      } else {
 | 
			
		||||
        maxPeople = true;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, "Can't search people");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 &&
 | 
			
		||||
    (thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
 | 
			
		||||
 | 
			
		||||
  $: {
 | 
			
		||||
    suggestedPeople = !name
 | 
			
		||||
      ? []
 | 
			
		||||
      : people
 | 
			
		||||
          .filter(
 | 
			
		||||
            (person: PersonResponseDto) =>
 | 
			
		||||
              person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
 | 
			
		||||
          )
 | 
			
		||||
          .slice(0, 5);
 | 
			
		||||
    if (people) {
 | 
			
		||||
      suggestedPeople = !name
 | 
			
		||||
        ? []
 | 
			
		||||
        : people
 | 
			
		||||
            .filter(
 | 
			
		||||
              (person: PersonResponseDto) =>
 | 
			
		||||
                person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
 | 
			
		||||
            )
 | 
			
		||||
            .slice(0, 5);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onMount(() => {
 | 
			
		||||
@@ -199,18 +239,11 @@
 | 
			
		||||
    try {
 | 
			
		||||
      isEditingName = false;
 | 
			
		||||
 | 
			
		||||
      const { data: updatedPerson } = await api.personApi.updatePerson({
 | 
			
		||||
      await api.personApi.updatePerson({
 | 
			
		||||
        id: data.person.id,
 | 
			
		||||
        personUpdateDto: { name: personName },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      people = people.map((person: PersonResponseDto) => {
 | 
			
		||||
        if (person.id === updatedPerson.id) {
 | 
			
		||||
          return updatedPerson;
 | 
			
		||||
        }
 | 
			
		||||
        return person;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      notificationController.show({
 | 
			
		||||
        message: 'Change name succesfully',
 | 
			
		||||
        type: NotificationType.Info,
 | 
			
		||||
@@ -235,15 +268,21 @@
 | 
			
		||||
    if (data.person.name === personName) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (name === '') {
 | 
			
		||||
      changeName();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const existingPerson = people.find(
 | 
			
		||||
    const result = await api.searchApi.searchPerson({ name: personName });
 | 
			
		||||
 | 
			
		||||
    const existingPerson = result.data.find(
 | 
			
		||||
      (person: PersonResponseDto) =>
 | 
			
		||||
        person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
 | 
			
		||||
    );
 | 
			
		||||
    if (existingPerson) {
 | 
			
		||||
      personMerge2 = existingPerson;
 | 
			
		||||
      personMerge1 = data.person;
 | 
			
		||||
      potentialMergePeople = people
 | 
			
		||||
      potentialMergePeople = result.data
 | 
			
		||||
        .filter(
 | 
			
		||||
          (person: PersonResponseDto) =>
 | 
			
		||||
            personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
 | 
			
		||||
@@ -310,7 +349,7 @@
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
{#if viewMode === ViewMode.MERGE_FACES}
 | 
			
		||||
  <MergeFaceSelector person={data.person} bind:people on:go-back={handleGoBack} on:merge={handleMerge} />
 | 
			
		||||
  <MergeFaceSelector person={data.person} on:go-back={handleGoBack} on:merge={handleMerge} />
 | 
			
		||||
{/if}
 | 
			
		||||
 | 
			
		||||
<header>
 | 
			
		||||
@@ -374,7 +413,7 @@
 | 
			
		||||
            {#if isEditingName}
 | 
			
		||||
              <EditNameInput
 | 
			
		||||
                person={data.person}
 | 
			
		||||
                suggestedPeople={suggestedPeople.length > 0}
 | 
			
		||||
                suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople}
 | 
			
		||||
                bind:name
 | 
			
		||||
                on:change={(event) => handleNameChange(event.detail)}
 | 
			
		||||
              />
 | 
			
		||||
@@ -406,25 +445,35 @@
 | 
			
		||||
          </section>
 | 
			
		||||
          {#if isEditingName}
 | 
			
		||||
            <div class="absolute z-[999] w-96">
 | 
			
		||||
              {#each suggestedPeople as person, index (person.id)}
 | 
			
		||||
              {#if isSearchingPeople}
 | 
			
		||||
                <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 rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700"
 | 
			
		||||
                >
 | 
			
		||||
                  <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
 | 
			
		||||
                    <ImageThumbnail
 | 
			
		||||
                      circle
 | 
			
		||||
                      shadow
 | 
			
		||||
                      url={api.getPeopleThumbnailUrl(person.id)}
 | 
			
		||||
                      altText={person.name}
 | 
			
		||||
                      widthStyle="2rem"
 | 
			
		||||
                      heightStyle="2rem"
 | 
			
		||||
                    />
 | 
			
		||||
                    <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
 | 
			
		||||
                  </button>
 | 
			
		||||
                  <div class="flex w-full place-items-center">
 | 
			
		||||
                    <LoadingSpinner />
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              {/each}
 | 
			
		||||
              {: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"
 | 
			
		||||
                  >
 | 
			
		||||
                    <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
 | 
			
		||||
                      <ImageThumbnail
 | 
			
		||||
                        circle
 | 
			
		||||
                        shadow
 | 
			
		||||
                        url={api.getPeopleThumbnailUrl(person.id)}
 | 
			
		||||
                        altText={person.name}
 | 
			
		||||
                        widthStyle="2rem"
 | 
			
		||||
                        heightStyle="2rem"
 | 
			
		||||
                      />
 | 
			
		||||
                      <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
 | 
			
		||||
                    </button>
 | 
			
		||||
                  </div>
 | 
			
		||||
                {/each}
 | 
			
		||||
              {/if}
 | 
			
		||||
            </div>
 | 
			
		||||
          {/if}
 | 
			
		||||
        </div>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user