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:
martin
2023-10-24 17:53:49 +02:00
committed by GitHub
parent 1aae29a0b8
commit 3e3598fd92
29 changed files with 736 additions and 80 deletions

View File

@@ -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));
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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',
},

View File

@@ -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