mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00: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:
		| @@ -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 /> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user