mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(web): suggest to merge people faces when renaming a person name (#3399)
* feat: propose to merge faced based on the name * responsive * drop down menu * add border * improvements * improvements * improvements * add comments * responsive * responsive * feat: use FullScreenModal * responsive * pr feeback * pr feeback * pr feeback * responsive * pr feeback * pr feeback * styling * fix test --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
26085ff82b
commit
afb0d0f54d
@ -14,6 +14,7 @@
|
||||
export let shadow = false;
|
||||
export let circle = false;
|
||||
export let hidden = false;
|
||||
export let border = false;
|
||||
let complete = false;
|
||||
|
||||
export let eyeColor = 'white';
|
||||
@ -26,7 +27,9 @@
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={altText}
|
||||
class="object-cover transition duration-300"
|
||||
class="object-cover transition duration-300 {border
|
||||
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
|
||||
: ''}"
|
||||
class:rounded-lg={curve}
|
||||
class:shadow-lg={shadow}
|
||||
class:rounded-full={circle}
|
||||
|
128
web/src/lib/components/faces-page/merge-suggestion-modal.svelte
Normal file
128
web/src/lib/components/faces-page/merge-suggestion-modal.svelte
Normal file
@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import type { PersonResponseDto } from '../../../api/open-api';
|
||||
import { api } from '@api';
|
||||
import Merge from 'svelte-material-icons/Merge.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
reject: void;
|
||||
confirm: [PersonResponseDto, PersonResponseDto];
|
||||
close: void;
|
||||
}>();
|
||||
|
||||
export let personMerge1: PersonResponseDto;
|
||||
export let personMerge2: PersonResponseDto;
|
||||
export let people: PersonResponseDto[];
|
||||
let potentialMergePeople: PersonResponseDto[] = people
|
||||
.filter(
|
||||
(person: PersonResponseDto) =>
|
||||
personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
|
||||
person.id !== personMerge2.id &&
|
||||
person.id !== personMerge1.id &&
|
||||
!person.isHidden,
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
let choosePersonToMerge = false;
|
||||
|
||||
const title = personMerge2.name;
|
||||
|
||||
const changePersonToMerge = (newperson: PersonResponseDto) => {
|
||||
const index = potentialMergePeople.indexOf(newperson);
|
||||
[potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]];
|
||||
choosePersonToMerge = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||
<div
|
||||
class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
|
||||
>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
Merge faces - {title}
|
||||
</h1>
|
||||
<CircleIconButton logo={Close} on:click={() => dispatch('close')} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
|
||||
{#if !choosePersonToMerge}
|
||||
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(personMerge1.id)}
|
||||
altText={personMerge1.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-0.5 flex md:mx-2">
|
||||
<CircleIconButton
|
||||
logo={Merge}
|
||||
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={potentialMergePeople.length === 0}
|
||||
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
|
||||
on:click={() => {
|
||||
if (potentialMergePeople.length > 0) {
|
||||
choosePersonToMerge = !choosePersonToMerge;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
border={potentialMergePeople.length !== 0}
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(personMerge2.id)}
|
||||
altText={personMerge2.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid w-full grid-cols-1 gap-2">
|
||||
<div class="px-2">
|
||||
<button on:click={() => (choosePersonToMerge = false)}> <ArrowLeft /></button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
||||
{#each potentialMergePeople as person (person.id)}
|
||||
<div class="h-24 w-24 md:h-28 md:w-28">
|
||||
<button class="p-2" on:click={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
on:click={() => changePersonToMerge(person)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex px-4 md:px-8 md:pt-4">
|
||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
|
||||
</div>
|
||||
<div class="flex px-4 pt-2 md:px-8">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
|
||||
</div>
|
||||
<div class="mt-8 flex w-full gap-4 px-4 pb-4">
|
||||
<Button color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
|
||||
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -19,6 +19,7 @@
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
let selectHidden = false;
|
||||
@ -33,6 +34,13 @@
|
||||
let showLoadingSpinner = false;
|
||||
let toggleVisibility = false;
|
||||
|
||||
let showChangeNameModal = false;
|
||||
let showMergeModal = false;
|
||||
let personName = '';
|
||||
let personMerge1: PersonResponseDto;
|
||||
let personMerge2: PersonResponseDto;
|
||||
let edittingPerson: PersonResponseDto | null = null;
|
||||
|
||||
people.forEach((person: PersonResponseDto) => {
|
||||
initialHiddenValues[person.id] = person.isHidden;
|
||||
});
|
||||
@ -136,13 +144,60 @@
|
||||
toggleVisibility = false;
|
||||
};
|
||||
|
||||
let showChangeNameModal = false;
|
||||
let personName = '';
|
||||
let edittingPerson: PersonResponseDto | null = null;
|
||||
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
|
||||
const [personToMerge, personToBeMergedIn] = response;
|
||||
showMergeModal = false;
|
||||
|
||||
if (!edittingPerson) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.personApi.mergePerson({
|
||||
id: personMerge2.id,
|
||||
mergePersonDto: { ids: [personToMerge.id] },
|
||||
});
|
||||
countVisiblePeople--;
|
||||
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
|
||||
|
||||
notificationController.show({
|
||||
message: 'Merge faces succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
if (personToBeMergedIn.name !== personName && edittingPerson.id === personToBeMergedIn.id) {
|
||||
/*
|
||||
*
|
||||
* If the user merges one of the suggested people into the person he's editing it, it's merging the suggested person AND renames
|
||||
* the person he's editing
|
||||
*
|
||||
*/
|
||||
try {
|
||||
await api.personApi.updatePerson({ id: personToBeMergedIn.id, personUpdateDto: { name: personName } });
|
||||
for (const person of people) {
|
||||
if (person.id === personToBeMergedIn.id) {
|
||||
person.name = personName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
notificationController.show({
|
||||
message: 'Change name succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
|
||||
// trigger reactivity
|
||||
people = people;
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeName = ({ detail }: CustomEvent<PersonResponseDto>) => {
|
||||
showChangeNameModal = true;
|
||||
personName = detail.name;
|
||||
personMerge1 = detail;
|
||||
edittingPerson = detail;
|
||||
};
|
||||
|
||||
@ -182,33 +237,73 @@
|
||||
};
|
||||
|
||||
const submitNameChange = async () => {
|
||||
showChangeNameModal = false;
|
||||
if (!edittingPerson) {
|
||||
return;
|
||||
}
|
||||
if (personName === edittingPerson.name) {
|
||||
return;
|
||||
}
|
||||
// We check if another person has the same name as the name entered by the user
|
||||
|
||||
const existingPerson = people.find(
|
||||
(person: PersonResponseDto) =>
|
||||
person.name.toLowerCase() === personName.toLowerCase() &&
|
||||
edittingPerson &&
|
||||
person.id !== edittingPerson.id &&
|
||||
person.name,
|
||||
);
|
||||
if (existingPerson) {
|
||||
personMerge2 = existingPerson;
|
||||
showMergeModal = true;
|
||||
return;
|
||||
}
|
||||
changeName();
|
||||
};
|
||||
|
||||
const changeName = async () => {
|
||||
showMergeModal = false;
|
||||
showChangeNameModal = false;
|
||||
|
||||
if (!edittingPerson) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (edittingPerson) {
|
||||
const { data: updatedPerson } = await api.personApi.updatePerson({
|
||||
id: edittingPerson.id,
|
||||
personUpdateDto: { name: personName },
|
||||
});
|
||||
const { data: updatedPerson } = await api.personApi.updatePerson({
|
||||
id: edittingPerson.id,
|
||||
personUpdateDto: { name: personName },
|
||||
});
|
||||
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
|
||||
showChangeNameModal = false;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Change name succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
notificationController.show({
|
||||
message: 'Change name succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if showMergeModal}
|
||||
<FullScreenModal on:clickOutside={() => (showMergeModal = false)}>
|
||||
<MergeSuggestionModal
|
||||
{personMerge1}
|
||||
{personMerge2}
|
||||
{people}
|
||||
on:close={() => (showMergeModal = false)}
|
||||
on:reject={() => changeName()}
|
||||
on:confirm={(event) => handleMergeSameFace(event.detail)}
|
||||
/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
<UserPageLayout user={data.user} title="People">
|
||||
<svelte:fragment slot="buttons">
|
||||
{#if countTotalPeople > 0}
|
||||
|
@ -10,11 +10,13 @@ export const load = (async ({ locals, parent, params }) => {
|
||||
|
||||
const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
|
||||
const { data: assets } = await locals.api.personApi.getPersonAssets({ id: params.personId });
|
||||
const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: false });
|
||||
|
||||
return {
|
||||
user,
|
||||
assets,
|
||||
person,
|
||||
people,
|
||||
meta: {
|
||||
title: person.name || 'Person',
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, goto, invalidateAll } 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';
|
||||
@ -15,7 +15,7 @@
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, api } from '@api';
|
||||
import { AssetResponseDto, PersonResponseDto, api } from '@api';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import Plus from 'svelte-material-icons/Plus.svelte';
|
||||
@ -30,6 +30,8 @@
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
let isEditingName = false;
|
||||
@ -37,6 +39,13 @@
|
||||
let showMergeFacePanel = false;
|
||||
let previousRoute: string = AppRoute.EXPLORE;
|
||||
let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
let showMergeModal = false;
|
||||
let people = data.people.people;
|
||||
let personMerge1: PersonResponseDto;
|
||||
let personMerge2: PersonResponseDto;
|
||||
|
||||
let personName = '';
|
||||
|
||||
$: isMultiSelectionMode = selectedAssets.size > 0;
|
||||
$: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
|
||||
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
|
||||
@ -56,16 +65,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleNameChange = async (name: string) => {
|
||||
try {
|
||||
isEditingName = false;
|
||||
data.person.name = name;
|
||||
await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetDelete = (assetId: string) => {
|
||||
data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
|
||||
};
|
||||
@ -91,8 +90,92 @@
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
|
||||
const [personToMerge, personToBeMergedIn] = response;
|
||||
showMergeModal = false;
|
||||
try {
|
||||
await api.personApi.mergePerson({
|
||||
id: personToBeMergedIn.id,
|
||||
mergePersonDto: { ids: [personToMerge.id] },
|
||||
});
|
||||
notificationController.show({
|
||||
message: 'Merge faces succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
|
||||
if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) {
|
||||
changeName();
|
||||
invalidateAll();
|
||||
return;
|
||||
}
|
||||
goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
};
|
||||
|
||||
const changeName = async () => {
|
||||
showMergeModal = false;
|
||||
data.person.name = personName;
|
||||
try {
|
||||
isEditingName = false;
|
||||
|
||||
const { data: updatedPerson } = await api.personApi.updatePerson({
|
||||
id: data.person.id,
|
||||
personUpdateDto: { name: personName },
|
||||
});
|
||||
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
message: 'Change name succesfully',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to save name');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = async (name: string) => {
|
||||
personName = name;
|
||||
|
||||
if (data.person.name === personName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPerson = people.find(
|
||||
(person: PersonResponseDto) =>
|
||||
person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
|
||||
);
|
||||
if (existingPerson) {
|
||||
personMerge2 = existingPerson;
|
||||
personMerge1 = data.person;
|
||||
showMergeModal = true;
|
||||
return;
|
||||
}
|
||||
changeName();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if showMergeModal}
|
||||
<FullScreenModal on:clickOutside={() => (showMergeModal = false)}>
|
||||
<MergeSuggestionModal
|
||||
{personMerge1}
|
||||
{personMerge2}
|
||||
{people}
|
||||
on:close={() => (showMergeModal = false)}
|
||||
on:reject={() => changeName()}
|
||||
on:confirm={(event) => handleMergeSameFace(event.detail)}
|
||||
/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if isMultiSelectionMode}
|
||||
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
|
||||
<CreateSharedLink />
|
||||
|
Loading…
Reference in New Issue
Block a user