Files
immich/web/src/lib/components/faces-page/assign-face-side-panel.svelte
Brandon Wees a02e1f5e7c chore(web): migrate CircleIconButton to @immich/ui IconButton (#18486)
* remove import and referenced file

* first pass at replacing all CircleIconButtons

* fix linting issues

* fix combobox formatting issues

* fix button context menu coloring

* remove circle icon button from search history box

* use theme switcher from UI lib

* dark mode force the asset viewer icons

* fix forced dark mode icons

* dark mode memory viewer icons

* fix: back button in memory viewer

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-02 14:47:23 +00:00

189 lines
6.2 KiB
Svelte

<script lang="ts">
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { AssetTypeEnum, getAllPeople, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
editedFace: AssetFaceResponseDto;
assetId: string;
assetType: AssetTypeEnum;
onClose: () => void;
onCreatePerson: (featurePhoto: string | null) => void;
onReassign: (person: PersonResponseDto) => void;
}
let { editedFace, assetId, assetType, onClose, onCreatePerson, onReassign }: Props = $props();
let allPeople: PersonResponseDto[] = $state([]);
let isShowLoadingPeople = $state(false);
async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
try {
const { people } = await getAllPeople({ withHidden: true, closestAssetId: editedFace.id });
allPeople = people;
} catch (error) {
handleError(error, $t('errors.cant_get_faces'));
} finally {
clearTimeout(timeout);
}
isShowLoadingPeople = false;
}
// loading spinners
let isShowLoadingNewPerson = $state(false);
let isShowLoadingSearch = $state(false);
// search people
let searchedPeople: PersonResponseDto[] = $state([]);
let searchFaces = $state(false);
let searchName = $state('');
let showPeople = $derived(searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden));
onMount(() => {
handlePromiseError(loadPeople());
});
const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement);
onCreatePerson(newFeaturePhoto);
clearTimeout(timeout);
isShowLoadingNewPerson = false;
onCreatePerson(newFeaturePhoto);
};
</script>
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 h-full w-[360px] overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}
<div class="flex items-center gap-2">
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiArrowLeftThin}
aria-label={$t('back')}
onclick={onClose}
/>
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('select_face')}</p>
</div>
<div class="flex justify-end gap-2">
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiMagnify}
aria-label={$t('search_for_existing_person')}
onclick={() => {
searchFaces = true;
}}
/>
{#if !isShowLoadingNewPerson}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiPlus}
aria-label={$t('create_new_person')}
onclick={handleCreatePerson}
/>
{:else}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
</div>
{:else}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiArrowLeftThin}
aria-label={$t('back')}
onclick={onClose}
/>
<div class="w-full flex">
<SearchPeople
type="input"
bind:searchName
bind:showLoadingSpinner={isShowLoadingSearch}
bind:searchedPeopleLocal={searchedPeople}
/>
{#if isShowLoadingSearch}
<div>
<LoadingSpinner />
</div>
{/if}
</div>
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiClose}
aria-label={$t('cancel_search')}
onclick={() => (searchFaces = false)}
/>
{/if}
</div>
<div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">{$t('all_people')}</h2>
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
{:else}
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#each showPeople as person (person.id)}
{#if !editedFace.person || person.id !== editedFace.person.id}
<div class="w-fit">
<button type="button" class="w-[90px]" onclick={() => onReassign(person)}>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
/>
</div>
<p
class="mt-1 truncate font-medium"
title={$getPersonNameWithHiddenValue(person.name, person.isHidden)}
>
{person.name}
</p>
</button>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</section>