Selaa lähdekoodia

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>
martin 2 vuotta sitten
vanhempi
commit
afb0d0f54d

+ 4 - 1
web/src/lib/components/assets/thumbnail/image-thumbnail.svelte

@@ -14,6 +14,7 @@
   export let shadow = false;
   export let shadow = false;
   export let circle = false;
   export let circle = false;
   export let hidden = false;
   export let hidden = false;
+  export let border = false;
   let complete = false;
   let complete = false;
 
 
   export let eyeColor = 'white';
   export let eyeColor = 'white';
@@ -26,7 +27,9 @@
   style:opacity={hidden ? '0.5' : '1'}
   style:opacity={hidden ? '0.5' : '1'}
   src={url}
   src={url}
   alt={altText}
   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:rounded-lg={curve}
   class:shadow-lg={shadow}
   class:shadow-lg={shadow}
   class:rounded-full={circle}
   class:rounded-full={circle}

+ 128 - 0
web/src/lib/components/faces-page/merge-suggestion-modal.svelte

@@ -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>

+ 116 - 21
web/src/routes/(user)/people/+page.svelte

@@ -19,6 +19,7 @@
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import { onDestroy, onMount } from 'svelte';
   import { onDestroy, onMount } from 'svelte';
   import { browser } from '$app/environment';
   import { browser } from '$app/environment';
+  import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
 
 
   export let data: PageData;
   export let data: PageData;
   let selectHidden = false;
   let selectHidden = false;
@@ -33,6 +34,13 @@
   let showLoadingSpinner = false;
   let showLoadingSpinner = false;
   let toggleVisibility = 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) => {
   people.forEach((person: PersonResponseDto) => {
     initialHiddenValues[person.id] = person.isHidden;
     initialHiddenValues[person.id] = person.isHidden;
   });
   });
@@ -136,13 +144,60 @@
     toggleVisibility = false;
     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>) => {
   const handleChangeName = ({ detail }: CustomEvent<PersonResponseDto>) => {
     showChangeNameModal = true;
     showChangeNameModal = true;
     personName = detail.name;
     personName = detail.name;
+    personMerge1 = detail;
     edittingPerson = detail;
     edittingPerson = detail;
   };
   };
 
 
@@ -182,33 +237,73 @@
   };
   };
 
 
   const submitNameChange = async () => {
   const submitNameChange = async () => {
-    try {
-      if (edittingPerson) {
-        const { data: updatedPerson } = await api.personApi.updatePerson({
-          id: edittingPerson.id,
-          personUpdateDto: { name: personName },
-        });
+    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();
+  };
 
 
-        people = people.map((person: PersonResponseDto) => {
-          if (person.id === updatedPerson.id) {
-            return updatedPerson;
-          }
-          return person;
-        });
+  const changeName = async () => {
+    showMergeModal = false;
+    showChangeNameModal = false;
 
 
-        showChangeNameModal = false;
+    if (!edittingPerson) {
+      return;
+    }
+    try {
+      const { data: updatedPerson } = await api.personApi.updatePerson({
+        id: edittingPerson.id,
+        personUpdateDto: { name: personName },
+      });
 
 
-        notificationController.show({
-          message: 'Change name succesfully',
-          type: NotificationType.Info,
-        });
-      }
+      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) {
     } catch (error) {
       handleError(error, 'Unable to save name');
       handleError(error, 'Unable to save name');
     }
     }
   };
   };
 </script>
 </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">
 <UserPageLayout user={data.user} title="People">
   <svelte:fragment slot="buttons">
   <svelte:fragment slot="buttons">
     {#if countTotalPeople > 0}
     {#if countTotalPeople > 0}

+ 2 - 0
web/src/routes/(user)/people/[personId]/+page.server.ts

@@ -10,11 +10,13 @@ export const load = (async ({ locals, parent, params }) => {
 
 
   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
   const { data: assets } = await locals.api.personApi.getPersonAssets({ 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 {
   return {
     user,
     user,
     assets,
     assets,
     person,
     person,
+    people,
     meta: {
     meta: {
       title: person.name || 'Person',
       title: person.name || 'Person',
     },
     },

+ 95 - 12
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
 <script lang="ts">
-  import { afterNavigate, goto } from '$app/navigation';
+  import { afterNavigate, goto, invalidateAll } from '$app/navigation';
   import { page } from '$app/stores';
   import { page } from '$app/stores';
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import EditNameInput from '$lib/components/faces-page/edit-name-input.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 GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
   import { AppRoute } from '$lib/constants';
   import { AppRoute } from '$lib/constants';
   import { handleError } from '$lib/utils/handle-error';
   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 ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
   import Plus from 'svelte-material-icons/Plus.svelte';
@@ -30,6 +30,8 @@
   } from '$lib/components/shared-components/notification/notification';
   } from '$lib/components/shared-components/notification/notification';
   import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
   import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
   import { onMount } from '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;
   export let data: PageData;
   let isEditingName = false;
   let isEditingName = false;
@@ -37,6 +39,13 @@
   let showMergeFacePanel = false;
   let showMergeFacePanel = false;
   let previousRoute: string = AppRoute.EXPLORE;
   let previousRoute: string = AppRoute.EXPLORE;
   let selectedAssets: Set<AssetResponseDto> = new Set();
   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;
   $: isMultiSelectionMode = selectedAssets.size > 0;
   $: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
   $: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
   $: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
   $: 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) => {
   const onAssetDelete = (assetId: string) => {
     data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
     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>
 </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}
 {#if isMultiSelectionMode}
   <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
   <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
     <CreateSharedLink />
     <CreateSharedLink />