From a3ffe3b3c3be79584366d4b482d9f57526f0ed0f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 26 Oct 2026 15:08:32 -0400 Subject: [PATCH] refactor: asset face queries (#32679) (cherry picked from commit 5077a85d5e6775c48b38de7fcf25f2a3864b6a83) --- web/src/app.css | 4 +- .../face-editor/FaceEditor.svelte | 49 ++++---- .../faces-page/PersonSidePanel.svelte | 111 ++++++++---------- web/src/lib/modals/CreateFaceModal.svelte | 46 ++++---- web/src/lib/queries/face.query.ts | 33 ++++++ 5 files changed, 123 insertions(+), 120 deletions(-) create mode 100644 web/src/lib/queries/face.query.ts diff --git a/web/src/app.css b/web/src/app.css index a4f8f48b1..15ee50654 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -93,7 +93,7 @@ @layer utilities { @font-face { font-family: 'GoogleSans'; - src: url('$lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations'); + src: url('#lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations'); font-weight: 410 900; font-style: normal; ascent-override: 106.25%; @@ -102,7 +102,7 @@ @font-face { font-family: 'GoogleSansCode'; - src: url('$lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations'); + src: url('#lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations'); font-weight: 1 900; font-style: monospace; } diff --git a/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte b/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte index d0a7a43d5..19222f1ee 100644 --- a/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/FaceEditor.svelte @@ -3,12 +3,14 @@ import ImageThumbnail from '#lib/components/assets/thumbnail/ImageThumbnail.svelte'; import { assetViewerManager } from '#lib/managers/asset-viewer-manager.svelte.ts'; import FaceCreateTagModal from '#lib/modals/CreateFaceModal.svelte'; + import { executeMutation } from '#lib/queries/client.ts'; + import { createFaceMutation } from '#lib/queries/face.query.ts'; import { faceManager } from '#lib/stores/face.svelte.ts'; import { getPeopleThumbnailUrl } from '#lib/utils.ts'; import { getNaturalSize, scaleToFit } from '#lib/utils/container-utils.ts'; import { handleError } from '#lib/utils/handle-error.ts'; import { normalizeSearchString } from '#lib/utils/string-utils.ts'; - import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; + import { getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { clamp } from 'lodash-es'; @@ -304,38 +306,29 @@ }; const tagFace = async (person: PersonResponseDto) => { - try { - const data = getFaceCroppedCoordinates(); - if (!data) { - toastManager.warning($t('error_tag_face_bounding_box')); - return; - } + const data = getFaceCroppedCoordinates(); + if (!data) { + toastManager.warning($t('error_tag_face_bounding_box')); + return; + } - const isConfirmed = await modalManager.showDialog({ - prompt: person.name - ? $t('confirm_tag_face', { values: { name: person.name } }) - : $t('confirm_tag_face_unnamed'), - }); + const isConfirmed = await modalManager.showDialog({ + prompt: person.name ? $t('confirm_tag_face', { values: { name: person.name } }) : $t('confirm_tag_face_unnamed'), + }); - if (!isConfirmed) { - return; - } - - await createFace({ - assetFaceCreateDto: { - assetId, - personId: person.id, - ...data, - }, - }); + if (!isConfirmed) { + return; + } + const onSuccess = async () => { await assetViewerManager.setAssetId(assetId); faceManager.clear(); - onClose(); - } catch (error) { - handleError(error, 'Error tagging face'); - onClose(); - } + }; + + await executeMutation(createFaceMutation, [{ assetFaceCreateDto: { assetId, personId: person.id, ...data } }], { + onSuccess: () => void onSuccess(), + onSettled: onClose, + }); }; const showCreateFaceModal = async () => { diff --git a/web/src/lib/components/faces-page/PersonSidePanel.svelte b/web/src/lib/components/faces-page/PersonSidePanel.svelte index 06e24dd51..67ea7d20f 100644 --- a/web/src/lib/components/faces-page/PersonSidePanel.svelte +++ b/web/src/lib/components/faces-page/PersonSidePanel.svelte @@ -5,22 +5,16 @@ import { assetViewerManager } from '#lib/managers/asset-viewer-manager.svelte.ts'; import { eventManager } from '#lib/managers/event-manager.svelte.ts'; import { languageManager } from '#lib/managers/language-manager.svelte.ts'; - import { getPeopleThumbnailUrl, handlePromiseError } from '#lib/utils.ts'; + import { executeMutation } from '#lib/queries/client.ts'; + import { deleteFaceMutation, getFacesQuery, reassignFacesByIdMutation } from '#lib/queries/face.query.ts'; + import { getPeopleThumbnailUrl } from '#lib/utils.ts'; import { handleError } from '#lib/utils/handle-error.ts'; import { zoomImageToBase64 } from '#lib/utils/people-utils.ts'; import { getPersonNameWithHiddenValue } from '#lib/utils/person.ts'; - import { - AssetTypeEnum, - createPerson, - deleteFace, - getFaces, - reassignFacesById, - type AssetFaceResponseDto, - type PersonResponseDto, - } from '@immich/sdk'; + import { AssetTypeEnum, createPerson, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk'; import { Icon, IconButton, LoadingSpinner, modalManager, toastManager } from '@immich/ui'; import { mdiAccountOff, mdiArrowLeftThin, mdiArrowRightThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js'; - import { onMount } from 'svelte'; + import { createQuery } from '@tanstack/svelte-query'; import { t } from 'svelte-i18n'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -40,15 +34,14 @@ let peopleToCreate: string[] = []; let assetFaceGenerated: string[] = []; - // faces - let peopleWithFaces: AssetFaceResponseDto[] = $state([]); + const query = createQuery(() => getFacesQuery(assetId)); + const peopleWithFaces = $derived(query.data ?? []); let selectedPersonToReassign: Record = $state({}); let selectedPersonToCreate: Record = $state({}); let editedFace: AssetFaceResponseDto | undefined = $state(); // loading spinners let isShowLoadingDone = $state(false); - let isShowLoadingPeople = $state(false); // search people let showSelectedFaces = $state(false); @@ -59,18 +52,6 @@ const thumbnailWidth = '90px'; - async function loadPeople() { - const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); - try { - peopleWithFaces = await getFaces({ id: assetId }); - } catch (error) { - handleError(error, $t('errors.cant_get_faces')); - } finally { - clearTimeout(timeout); - } - isShowLoadingPeople = false; - } - const onPersonThumbnailReady = ({ id }: { id: string }) => { assetFaceGenerated.push(id); if ( @@ -85,10 +66,6 @@ } }; - onMount(() => { - handlePromiseError(loadPeople()); - }); - const isEqual = (a: string[], b: string[]): boolean => { return b.every((valueB) => a.includes(valueB)); }; @@ -107,28 +84,36 @@ const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length; if (numberOfChanges > 0) { + let failed = false; try { for (const personWithFace of peopleWithFaces) { - const personId = selectedPersonToReassign[personWithFace.id]?.id; + let personId = selectedPersonToReassign[personWithFace.id]?.id; - if (personId) { - await reassignFacesById({ - id: personId, - faceDto: { id: personWithFace.id }, - }); - } else if (Object.hasOwn(selectedPersonToCreate, personWithFace.id)) { + if (!personId && Object.hasOwn(selectedPersonToCreate, personWithFace.id)) { const data = await createPerson({ personCreateDto: {} }); peopleToCreate.push(data.id); - await reassignFacesById({ - id: data.id, - faceDto: { id: personWithFace.id }, - }); + personId = data.id; + } + + if (!personId) { + continue; + } + + await executeMutation(reassignFacesByIdMutation, [{ id: personId, faceDto: { id: personWithFace.id } }], { + onError: () => (failed = true), + }); + + if (failed) { + break; } } - - toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } })); } catch (error) { handleError(error, $t('errors.cant_apply_changes')); + failed = true; + } + + if (!failed) { + toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } })); } } @@ -161,28 +146,24 @@ }; const deleteAssetFace = async (face: AssetFaceResponseDto) => { - try { - if (!face.person) { - return; - } - - const isConfirmed = await modalManager.showDialog({ - prompt: $t('confirm_delete_face', { values: { name: face.person.name } }), - }); - if (!isConfirmed) { - return; - } - - await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } }); - - eventManager.emit('PersonAssetDelete', { id: face.person.id, assetId }); - - peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id); - - await assetViewerManager.setAssetId(assetId); - } catch (error) { - handleError(error, $t('error_delete_face')); + const person = face.person; + if (!person) { + return; } + + const isConfirmed = await modalManager.showDialog({ + prompt: $t('confirm_delete_face', { values: { name: person.name } }), + }); + if (!isConfirmed) { + return; + } + + await executeMutation(deleteFaceMutation, [{ id: face.id, assetFaceDeleteDto: { force: false } }], { + onSuccess: () => { + eventManager.emit('PersonAssetDelete', { id: person.id, assetId }); + void assetViewerManager.setAssetId(assetId); + }, + }); }; @@ -232,7 +213,7 @@
- {#if isShowLoadingPeople} + {#if query.isPending}
diff --git a/web/src/lib/modals/CreateFaceModal.svelte b/web/src/lib/modals/CreateFaceModal.svelte index a42e6b451..633b1d9ca 100644 --- a/web/src/lib/modals/CreateFaceModal.svelte +++ b/web/src/lib/modals/CreateFaceModal.svelte @@ -1,8 +1,10 @@ diff --git a/web/src/lib/queries/face.query.ts b/web/src/lib/queries/face.query.ts new file mode 100644 index 000000000..0bb4e97b3 --- /dev/null +++ b/web/src/lib/queries/face.query.ts @@ -0,0 +1,33 @@ +import { asMutation, queryClient } from '#lib/queries/client.ts'; +import { createFace, deleteFace, getFaces, reassignFacesById, type AssetFaceResponseDto } from '@immich/sdk'; +import { queryOptions } from '@tanstack/svelte-query'; + +export const getFacesQuery = (assetId: string) => + queryOptions({ + queryKey: ['faces', assetId], + queryFn: ({ signal }) => getFaces({ id: assetId }, { signal }), + }); + +export const createFaceMutation = () => + asMutation(createFace, { + onSuccess: (_, [{ assetFaceCreateDto }]) => + queryClient.invalidateQueries({ queryKey: getFacesQuery(assetFaceCreateDto.assetId).queryKey }), + }); + +export const deleteFaceMutation = () => + asMutation(deleteFace, { + onSuccess: (_, [{ id }]) => + queryClient.setQueriesData({ queryKey: ['faces'] }, (faces) => + faces?.filter((face) => face.id !== id), + ), + meta: { error: { toast: 'error_delete_face' } }, + }); + +export const reassignFacesByIdMutation = () => + asMutation(reassignFacesById, { + onSuccess: (person, [{ faceDto }]) => + queryClient.setQueriesData({ queryKey: ['faces'] }, (faces) => + faces?.map((face) => (face.id === faceDto.id ? { ...face, person } : face)), + ), + meta: { error: { toast: 'errors.cant_apply_changes' } }, + });