mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): manual face tagging and deletion (#16062)
This commit is contained in:
parent
94c0e8253a
commit
007eaaceb9
35 changed files with 2054 additions and 106 deletions
|
|
@ -25,7 +25,6 @@
|
|||
type ExifResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
mdiAccountOff,
|
||||
mdiCalendar,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
|
|
@ -34,6 +33,7 @@
|
|||
mdiImageOutline,
|
||||
mdiInformationOutline,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
|
@ -46,6 +46,7 @@
|
|||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
|
|
@ -186,20 +187,11 @@
|
|||
<DetailPanelDescription {asset} {isOwner} />
|
||||
<DetailPanelRating {asset} {isOwner} />
|
||||
|
||||
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
|
||||
{#if !isSharedLink() && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2>{$t('people').toUpperCase()}</h2>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if unassignedFaces.length > 0}
|
||||
<Icon
|
||||
ariaLabel={$t('asset_has_unassigned_faces')}
|
||||
title={$t('asset_has_unassigned_faces')}
|
||||
color="currentColor"
|
||||
path={mdiAccountOff}
|
||||
size="24"
|
||||
/>
|
||||
{/if}
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<CircleIconButton
|
||||
title={$t('show_hidden_people')}
|
||||
|
|
@ -210,13 +202,24 @@
|
|||
/>
|
||||
{/if}
|
||||
<CircleIconButton
|
||||
title={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
title={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
padding="1"
|
||||
size="20"
|
||||
buttonSize="32"
|
||||
onclick={() => (showEditFaces = true)}
|
||||
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
<CircleIconButton
|
||||
title={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
padding="1"
|
||||
size="20"
|
||||
buttonSize="32"
|
||||
onclick={() => (showEditFaces = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { onMount } from 'svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
interface Props {
|
||||
imgElement: HTMLImageElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
let { imgElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
let faceRect: Rect | undefined = $state();
|
||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const configureControlStyle = () => {
|
||||
InteractiveFabricObject.ownDefaults = {
|
||||
...InteractiveFabricObject.ownDefaults,
|
||||
cornerStyle: 'circle',
|
||||
cornerColor: 'rgb(153,166,251)',
|
||||
cornerSize: 10,
|
||||
padding: 8,
|
||||
transparentCorners: false,
|
||||
lockRotation: true,
|
||||
hasBorders: true,
|
||||
};
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl || !imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas = new Canvas(canvasEl);
|
||||
configureControlStyle();
|
||||
|
||||
faceRect = new Rect({
|
||||
fill: 'rgba(66,80,175,0.25)',
|
||||
stroke: 'rgb(66,80,175)',
|
||||
strokeWidth: 2,
|
||||
strokeUniform: true,
|
||||
width: 112,
|
||||
height: 112,
|
||||
objectCaching: true,
|
||||
rx: 8,
|
||||
ry: 8,
|
||||
});
|
||||
|
||||
canvas.add(faceRect);
|
||||
canvas.setActiveObject(faceRect);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
setupCanvas();
|
||||
await getPeople();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
|
||||
const imageBoundingBox = {
|
||||
top: offsetArea.height,
|
||||
left: offsetArea.width,
|
||||
width: containerWidth - offsetArea.width * 2,
|
||||
height: containerHeight - offsetArea.height * 2,
|
||||
};
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
if (!faceRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
faceRect.set({
|
||||
top: imageBoundingBox.top + 200,
|
||||
left: imageBoundingBox.left + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { actualWidth: number; actualHeight: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let actualWidth = img.height * ratio;
|
||||
let actualHeight = img.height;
|
||||
if (actualWidth > img.width) {
|
||||
actualWidth = img.width;
|
||||
actualHeight = img.width / ratio;
|
||||
}
|
||||
return { actualWidth, actualHeight };
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
};
|
||||
|
||||
let page = $state(1);
|
||||
let candidates = $state<PersonResponseDto[]>([]);
|
||||
|
||||
const getPeople = async () => {
|
||||
const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false });
|
||||
|
||||
if (candidates.length === total) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidates = [...candidates, ...people];
|
||||
|
||||
if (hasNextPage) {
|
||||
page++;
|
||||
}
|
||||
};
|
||||
|
||||
const positionFaceSelector = () => {
|
||||
if (!faceRect || !faceSelectorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = faceRect.getBoundingRect();
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const selectorHeight = faceSelectorEl.offsetHeight;
|
||||
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = containerHeight - (rect.top + rect.height);
|
||||
const spaceLeft = rect.left;
|
||||
const spaceRight = containerWidth - (rect.left + rect.width);
|
||||
|
||||
let top, left;
|
||||
|
||||
if (
|
||||
spaceBelow >= selectorHeight ||
|
||||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
||||
) {
|
||||
top = rect.top + rect.height + 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceAbove >= selectorHeight ||
|
||||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
||||
) {
|
||||
top = rect.top - selectorHeight - 15;
|
||||
left = rect.left;
|
||||
} else if (
|
||||
spaceRight >= selectorWidth ||
|
||||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
||||
) {
|
||||
top = rect.top;
|
||||
left = rect.left + rect.width + 15;
|
||||
} else {
|
||||
top = rect.top;
|
||||
left = rect.left - selectorWidth - 15;
|
||||
}
|
||||
|
||||
if (left + selectorWidth > containerWidth) {
|
||||
left = containerWidth - selectorWidth - 15;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 15;
|
||||
}
|
||||
|
||||
if (top + selectorHeight > containerHeight) {
|
||||
top = containerHeight - selectorHeight - 15;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 15;
|
||||
}
|
||||
|
||||
faceSelectorEl.style.top = `${top}px`;
|
||||
faceSelectorEl.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (faceRect) {
|
||||
faceRect.on('moving', positionFaceSelector);
|
||||
faceRect.on('scaling', positionFaceSelector);
|
||||
}
|
||||
});
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || !imgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
height: (containerHeight - actualHeight) / 2,
|
||||
};
|
||||
|
||||
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
||||
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
||||
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
||||
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
||||
|
||||
// transpose to the natural image location
|
||||
const x1 = x1Coeff * imgElement.naturalWidth;
|
||||
const y1 = y1Coeff * imgElement.naturalHeight;
|
||||
const x2 = x2Coeff * imgElement.naturalWidth;
|
||||
const y2 = y2Coeff * imgElement.naturalHeight;
|
||||
|
||||
return {
|
||||
imageWidth: imgElement.naturalWidth,
|
||||
imageHeight: imgElement.naturalHeight,
|
||||
x: Math.floor(x1),
|
||||
y: Math.floor(y1),
|
||||
width: Math.floor(x2 - x1),
|
||||
height: Math.floor(y2 - y1),
|
||||
};
|
||||
};
|
||||
|
||||
const tagFace = async (person: PersonResponseDto) => {
|
||||
try {
|
||||
const data = getFaceCroppedCoordinates();
|
||||
if (!data) {
|
||||
notificationController.show({
|
||||
message: 'Error tagging face - cannot get bounding box coordinates',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await dialogController.show({
|
||||
prompt: `Do you want to tag this face as ${person.name}?`,
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createFace({
|
||||
assetFaceCreateDto: {
|
||||
assetId,
|
||||
personId: person.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
isFaceEditMode.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="absolute left-0 top-0">
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 left-0"></canvas>
|
||||
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="absolute top-[calc(50%-250px)] left-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200"
|
||||
>
|
||||
<p class="text-center text-sm">Select a person to tag</p>
|
||||
|
||||
<div class="max-h-[250px] overflow-y-auto mt-2">
|
||||
<div class="mt-2 rounded-lg">
|
||||
{#each candidates as person}
|
||||
<button
|
||||
onclick={() => tagFace(person)}
|
||||
type="button"
|
||||
class="w-full flex place-items-center gap-2 rounded-lg pl-1 pr-4 py-2 hover:bg-immich-primary/25"
|
||||
>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="30px"
|
||||
heightStyle="30px"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{person.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -7,6 +7,14 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
|||
import { render } from '@testing-library/svelte';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
vi.mock('$lib/utils', async (originalImport) => {
|
||||
const meta = await originalImport<typeof import('$lib/utils')>();
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
|
|
@ -19,6 +18,9 @@
|
|||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
|
|
@ -91,7 +93,7 @@
|
|||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard($photoViewer ?? assetFileUrl);
|
||||
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('copied_image_to_clipboard'),
|
||||
|
|
@ -106,6 +108,12 @@
|
|||
$zoomed = $zoomed ? false : true;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
|
||||
zoomToggle();
|
||||
}
|
||||
});
|
||||
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (globalThis.getSelection()?.type === 'Range') {
|
||||
return;
|
||||
|
|
@ -159,6 +167,9 @@
|
|||
});
|
||||
|
||||
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
|
|
@ -172,7 +183,12 @@
|
|||
{/if}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
<div bind:this={element} class="relative h-full select-none">
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
<img
|
||||
style="display:none"
|
||||
src={imageLoaderUrl}
|
||||
|
|
@ -201,7 +217,7 @@
|
|||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewer}
|
||||
bind:this={$photoViewerImgElement}
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
|
|
@ -209,13 +225,17 @@
|
|||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-[3px] rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor imgElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
|
||||
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
|
||||
light: 'bg-white hover:bg-[#d3d3d3]',
|
||||
red: 'text-red-400 hover:bg-[#d3d3d3]',
|
||||
red: 'text-red-400 bg-red-100 hover:bg-[#d3d3d3]',
|
||||
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
|
||||
alert: 'text-[#ff0000] hover:text-white',
|
||||
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||
|
||||
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer);
|
||||
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement);
|
||||
|
||||
onCreatePerson(newFeaturePhoto);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@
|
|||
AssetTypeEnum,
|
||||
type AssetFaceResponseDto,
|
||||
type PersonResponseDto,
|
||||
deleteFace,
|
||||
} from '@immich/sdk';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart } from '@mdi/js';
|
||||
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
|
@ -24,8 +25,10 @@
|
|||
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 { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
|
|
@ -163,6 +166,30 @@
|
|||
editedFace = face;
|
||||
showSelectedFaces = true;
|
||||
};
|
||||
|
||||
const deleteAssetFace = async (face: AssetFaceResponseDto) => {
|
||||
try {
|
||||
if (!face.person) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmed = await dialogController.show({
|
||||
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } });
|
||||
|
||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, $t('error_delete_face'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
|
|
@ -242,7 +269,7 @@
|
|||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{:else}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, $photoViewer)}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
|
|
@ -308,6 +335,19 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if face.person != null}
|
||||
<div class="absolute -right-[5px] top-[25px] h-[20px] w-[20px] rounded-full">
|
||||
<CircleIconButton
|
||||
color="red"
|
||||
icon={mdiTrashCan}
|
||||
title={$t('delete_face')}
|
||||
size="18"
|
||||
padding="1"
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||
onclick={() => deleteAssetFace(face)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue