mirror of
https://github.com/immich-app/immich
synced 2025-06-06 19:19:48 +00:00

* Squashed * Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation * Reduce jank on scroll, delay DOM updates until after scroll * css opt, log measure time * Trickle out queue while scrolling, flush when stopped * yay * Cleanup cleanup... * everybody... * everywhere... * Clean up cleanup! * Everybody do their share * CLEANUP! * package-lock ? * dynamic measure, todo * Fix web test * type lint * fix e2e * e2e test * Better scrollbar * Tuning, and more tunables * Tunable tweaks, more tunables * Scrollbar dots and viewport events * lint * Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes * New tunables, and don't update url by default * Bug fixes * Bug fix, with debug * Fix flickr, fix graybox bug, reduced debug * Refactor/cleanup * Fix * naming * Final cleanup * review comment * Forgot to update this after naming change * scrubber works, with debug * cleanup * Rename scrollbar to scrubber * rename to * left over rename and change to previous album bar * bugfix addassets, comments * missing destroy(), cleanup --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
344 lines
12 KiB
Svelte
344 lines
12 KiB
Svelte
<script lang="ts">
|
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
|
import { websocketEvents } from '$lib/stores/websocket';
|
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
|
import { handleError } from '$lib/utils/handle-error';
|
|
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
|
import {
|
|
createPerson,
|
|
getAllPeople,
|
|
getFaces,
|
|
reassignFacesById,
|
|
AssetTypeEnum,
|
|
type AssetFaceResponseDto,
|
|
type PersonResponseDto,
|
|
} from '@immich/sdk';
|
|
import { mdiAccountOff } from '@mdi/js';
|
|
import Icon from '$lib/components/elements/icon.svelte';
|
|
import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js';
|
|
import { createEventDispatcher, onMount } from 'svelte';
|
|
import { linear } from 'svelte/easing';
|
|
import { fly } from 'svelte/transition';
|
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
|
import { photoViewer } from '$lib/stores/assets.store';
|
|
import { t } from 'svelte-i18n';
|
|
|
|
export let assetId: string;
|
|
export let assetType: AssetTypeEnum;
|
|
|
|
// keep track of the changes
|
|
let peopleToCreate: string[] = [];
|
|
let assetFaceGenerated: string[] = [];
|
|
|
|
// faces
|
|
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
|
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
|
let selectedPersonToCreate: Record<string, string> = {};
|
|
let editedFace: AssetFaceResponseDto;
|
|
|
|
// loading spinners
|
|
let isShowLoadingDone = false;
|
|
let isShowLoadingPeople = false;
|
|
|
|
// search people
|
|
let showSelectedFaces = false;
|
|
let allPeople: PersonResponseDto[] = [];
|
|
|
|
// timers
|
|
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
|
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
|
|
|
const thumbnailWidth = '90px';
|
|
|
|
const dispatch = createEventDispatcher<{
|
|
close: void;
|
|
refresh: void;
|
|
}>();
|
|
|
|
async function loadPeople() {
|
|
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
|
try {
|
|
const { people } = await getAllPeople({ withHidden: true });
|
|
allPeople = people;
|
|
peopleWithFaces = await getFaces({ id: assetId });
|
|
} catch (error) {
|
|
handleError(error, $t('errors.cant_get_faces'));
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
isShowLoadingPeople = false;
|
|
}
|
|
|
|
const onPersonThumbnail = (personId: string) => {
|
|
assetFaceGenerated.push(personId);
|
|
if (
|
|
isEqual(assetFaceGenerated, peopleToCreate) &&
|
|
loaderLoadingDoneTimeout &&
|
|
automaticRefreshTimeout &&
|
|
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
|
) {
|
|
clearTimeout(loaderLoadingDoneTimeout);
|
|
clearTimeout(automaticRefreshTimeout);
|
|
dispatch('refresh');
|
|
}
|
|
};
|
|
|
|
onMount(() => {
|
|
handlePromiseError(loadPeople());
|
|
return websocketEvents.on('on_person_thumbnail', onPersonThumbnail);
|
|
});
|
|
|
|
const isEqual = (a: string[], b: string[]): boolean => {
|
|
return b.every((valueB) => a.includes(valueB));
|
|
};
|
|
|
|
const handleBackButton = () => {
|
|
dispatch('close');
|
|
};
|
|
|
|
const handleReset = (id: string) => {
|
|
if (selectedPersonToReassign[id]) {
|
|
delete selectedPersonToReassign[id];
|
|
|
|
// trigger reactivity
|
|
selectedPersonToReassign = selectedPersonToReassign;
|
|
}
|
|
if (selectedPersonToCreate[id]) {
|
|
delete selectedPersonToCreate[id];
|
|
|
|
// trigger reactivity
|
|
selectedPersonToCreate = selectedPersonToCreate;
|
|
}
|
|
};
|
|
|
|
const handleEditFaces = async () => {
|
|
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
|
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
|
|
|
if (numberOfChanges > 0) {
|
|
try {
|
|
for (const personWithFace of peopleWithFaces) {
|
|
const personId = selectedPersonToReassign[personWithFace.id]?.id;
|
|
|
|
if (personId) {
|
|
await reassignFacesById({
|
|
id: personId,
|
|
faceDto: { id: personWithFace.id },
|
|
});
|
|
} else if (selectedPersonToCreate[personWithFace.id]) {
|
|
const data = await createPerson({ personCreateDto: {} });
|
|
peopleToCreate.push(data.id);
|
|
await reassignFacesById({
|
|
id: data.id,
|
|
faceDto: { id: personWithFace.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
notificationController.show({
|
|
message: $t('people_edits_count', { values: { count: numberOfChanges } }),
|
|
type: NotificationType.Info,
|
|
});
|
|
} catch (error) {
|
|
handleError(error, $t('errors.cant_apply_changes'));
|
|
}
|
|
}
|
|
|
|
isShowLoadingDone = false;
|
|
if (peopleToCreate.length === 0) {
|
|
clearTimeout(loaderLoadingDoneTimeout);
|
|
dispatch('refresh');
|
|
} else {
|
|
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000);
|
|
}
|
|
};
|
|
|
|
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
|
if (newFeaturePhoto) {
|
|
selectedPersonToCreate[editedFace.id] = newFeaturePhoto;
|
|
}
|
|
showSelectedFaces = false;
|
|
};
|
|
|
|
const handleReassignFace = (person: PersonResponseDto | null) => {
|
|
if (person) {
|
|
selectedPersonToReassign[editedFace.id] = person;
|
|
}
|
|
showSelectedFaces = false;
|
|
};
|
|
|
|
const handleFacePicker = (face: AssetFaceResponseDto) => {
|
|
editedFace = face;
|
|
showSelectedFaces = true;
|
|
};
|
|
</script>
|
|
|
|
<section
|
|
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
|
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg"
|
|
>
|
|
<div class="flex place-items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<CircleIconButton icon={mdiArrowLeftThin} title={$t('back')} on:click={handleBackButton} />
|
|
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">{$t('edit_faces')}</p>
|
|
</div>
|
|
{#if !isShowLoadingDone}
|
|
<button
|
|
type="button"
|
|
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
|
on:click={() => handleEditFaces()}
|
|
>
|
|
{$t('done')}
|
|
</button>
|
|
{:else}
|
|
<LoadingSpinner />
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="px-4 py-4 text-sm">
|
|
<div class="mt-4 flex flex-wrap gap-2">
|
|
{#if isShowLoadingPeople}
|
|
<div class="flex w-full justify-center">
|
|
<LoadingSpinner />
|
|
</div>
|
|
{:else}
|
|
{#each peopleWithFaces as face, index}
|
|
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
|
<div class="relative z-[20001] h-[115px] w-[95px]">
|
|
<div
|
|
role="button"
|
|
tabindex={index}
|
|
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
|
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
|
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
|
>
|
|
<div class="relative">
|
|
{#if selectedPersonToCreate[face.id]}
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url={selectedPersonToCreate[face.id]}
|
|
altText={$t('new_person')}
|
|
title={$t('new_person')}
|
|
widthStyle={thumbnailWidth}
|
|
heightStyle={thumbnailWidth}
|
|
/>
|
|
{:else if selectedPersonToReassign[face.id]}
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id])}
|
|
altText={selectedPersonToReassign[face.id].name}
|
|
title={$getPersonNameWithHiddenValue(
|
|
selectedPersonToReassign[face.id].name,
|
|
selectedPersonToReassign[face.id]?.isHidden,
|
|
)}
|
|
widthStyle={thumbnailWidth}
|
|
heightStyle={thumbnailWidth}
|
|
hidden={selectedPersonToReassign[face.id].isHidden}
|
|
/>
|
|
{:else if face.person}
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url={getPeopleThumbnailUrl(face.person)}
|
|
altText={face.person.name}
|
|
title={$getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
|
widthStyle={thumbnailWidth}
|
|
heightStyle={thumbnailWidth}
|
|
hidden={face.person.isHidden}
|
|
/>
|
|
{:else}
|
|
{#await zoomImageToBase64(face, assetId, assetType, $photoViewer)}
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url="/src/lib/assets/no-thumbnail.png"
|
|
altText={$t('face_unassigned')}
|
|
title={$t('face_unassigned')}
|
|
widthStyle="90px"
|
|
heightStyle="90px"
|
|
/>
|
|
{:then data}
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url={data === null ? '/src/lib/assets/no-thumbnail.png' : data}
|
|
altText={$t('face_unassigned')}
|
|
title={$t('face_unassigned')}
|
|
widthStyle="90px"
|
|
heightStyle="90px"
|
|
/>
|
|
{/await}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !selectedPersonToCreate[face.id]}
|
|
<p class="relative mt-1 truncate font-medium" title={personName}>
|
|
{#if selectedPersonToReassign[face.id]?.id}
|
|
{selectedPersonToReassign[face.id]?.name}
|
|
{:else}
|
|
<span class={personName === $t('face_unassigned') ? 'dark:text-gray-500' : ''}>{personName}</span>
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
|
|
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full">
|
|
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
|
<CircleIconButton
|
|
color="primary"
|
|
icon={mdiRestart}
|
|
title={$t('reset')}
|
|
size="18"
|
|
padding="1"
|
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
|
on:click={() => handleReset(face.id)}
|
|
/>
|
|
{:else}
|
|
<CircleIconButton
|
|
color="primary"
|
|
icon={mdiMinus}
|
|
title={$t('select_new_face')}
|
|
size="18"
|
|
padding="1"
|
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
|
on:click={() => handleFacePicker(face)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<div class="absolute right-[25px] -top-[5px] h-[20px] w-[20px] rounded-full">
|
|
{#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id] && !face.person}
|
|
<div
|
|
class="flex place-content-center place-items-center rounded-full bg-[#d3d3d3] p-1 transition-all absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
|
>
|
|
<Icon color="primary" path={mdiAccountOff} ariaHidden size="18" />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{#if showSelectedFaces}
|
|
<AssignFaceSidePanel
|
|
{allPeople}
|
|
{editedFace}
|
|
{assetId}
|
|
{assetType}
|
|
on:close={() => (showSelectedFaces = false)}
|
|
on:createPerson={(event) => handleCreatePerson(event.detail)}
|
|
on:reassign={(event) => handleReassignFace(event.detail)}
|
|
/>
|
|
{/if}
|