From 65f5118bdd4349f46f4b40b29a82c10f1016a374 Mon Sep 17 00:00:00 2001
From: i-am-a-teapot <75271959+i-am-a-teapot@users.noreply.github.com>
Date: Tue, 6 Aug 2024 19:06:30 +0200
Subject: [PATCH] feat(web): Add stacking option to deduplication utilities
 (#11114)

* feat(web): Add stacking option to deduplication utilities

* Update web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte

Co-authored-by: Alex <alex.tran1502@gmail.com>

* Fix prettier

* Draft for server side modifications. Endpoint for stacks (PUT,DELETE)

* Fix error

* Disable stakc button if less or more than one asset selected

* Remove unnecesarry log

* Revert to first commit

* Further Revert

* Actually Revert to Origin

* Only one stack button

* Update +page.svelte

* Fix optional arguments

* Fix Prettier

* Fix Linting

* Add stack information to asset view

* clean up

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
---
 .../duplicates/duplicate-asset.svelte         | 25 +++++++---
 .../duplicates-compare-control.svelte         | 50 ++++++++++++++-----
 web/src/lib/i18n/en.json                      |  2 +
 web/src/lib/utils/asset-utils.ts              | 22 ++++----
 .../[[assetId=id]]/+page.svelte               | 13 ++++-
 5 files changed, 82 insertions(+), 30 deletions(-)

diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
index 74d17c621d..5fc2177e88 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte
@@ -4,7 +4,7 @@
   import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
   import { getAltText } from '$lib/utils/thumbnail-util';
   import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
-  import { mdiHeart, mdiMagnifyPlus } from '@mdi/js';
+  import { mdiHeart, mdiMagnifyPlus, mdiImageMultipleOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
 
   export let asset: AssetResponseDto;
@@ -14,6 +14,7 @@
 
   $: isFromExternalLibrary = !!asset.libraryId;
   $: assetData = JSON.stringify(asset, null, 2);
+  $: stackCount = asset.stackCount;
 </script>
 
 <div
@@ -54,12 +55,22 @@
         {isSelected ? $t('keep') : $t('to_trash')}
       </div>
 
-      <!-- EXTERNAL LIBRARY CHIP-->
-      {#if isFromExternalLibrary}
-        <div class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs text-white">
-          {$t('external')}
-        </div>
-      {/if}
+      <!-- EXTERNAL LIBRARY / STACK COUNT CHIP-->
+      <div class="absolute top-2 right-3">
+        {#if isFromExternalLibrary}
+          <div class="bg-immich-primary/90 px-2 py-1 rounded-xl text-xs text-white">
+            {$t('external')}
+          </div>
+        {/if}
+        {#if stackCount != null && stackCount != 0}
+          <div class="bg-immich-primary/90 px-2 py-1 my-0.5 rounded-xl text-xs text-white">
+            <div class="flex items-center justify-center">
+              <div class="mr-1">{stackCount}</div>
+              <Icon path={mdiImageMultipleOutline} size="18" />
+            </div>
+          </div>
+        {/if}
+      </div>
     </button>
 
     <button
diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
index c4015b80e5..fcf68fdb91 100644
--- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
+++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte
@@ -7,13 +7,13 @@
   import { suggestDuplicateByFileSize } from '$lib/utils';
   import { shortcuts } from '$lib/actions/shortcut';
   import { type AssetResponseDto } from '@immich/sdk';
-  import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
+  import { mdiCheck, mdiTrashCanOutline, mdiImageMultipleOutline } from '@mdi/js';
   import { onDestroy, onMount } from 'svelte';
   import { t } from 'svelte-i18n';
 
   export let assets: AssetResponseDto[];
   export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
-
+  export let onStack: (assets: AssetResponseDto[]) => void;
   const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
   const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
 
@@ -60,6 +60,10 @@
     const duplicateAssetIds = assets.map((asset) => asset.id);
     onResolve(duplicateAssetIds, trashIds);
   };
+
+  const handleStack = () => {
+    onStack(assets);
+  };
 </script>
 
 <svelte:window
@@ -73,6 +77,7 @@
     },
     { shortcut: { key: 'd' }, onShortcut: onSelectNone },
     { shortcut: { key: 'c', shift: true }, onShortcut: handleResolve },
+    { shortcut: { key: 's', shift: true }, onShortcut: handleStack },
   ]}
 />
 
@@ -104,17 +109,38 @@
     </div>
 
     <!-- CONFIRM BUTTONS -->
-    {#if trashCount === 0}
-      <Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}>
-        <Icon path={mdiCheck} size="20" />{$t('keep_all')}
+    <div class="flex text-xs text-black">
+      {#if trashCount === 0}
+        <Button
+          size="sm"
+          color="primary"
+          class="flex place-items-center rounded-tl-full rounded-bl-full gap-2"
+          on:click={handleResolve}
+        >
+          <Icon path={mdiCheck} size="20" />{$t('keep_all')}
+        </Button>
+      {:else}
+        <Button
+          size="sm"
+          color="red"
+          class="flex place-items-center rounded-tl-full rounded-bl-full gap-2 py-3"
+          on:click={handleResolve}
+        >
+          <Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
+            ? $t('trash_all')
+            : $t('trash_count', { values: { count: trashCount } })}
+        </Button>
+      {/if}
+      <Button
+        size="sm"
+        color="primary"
+        class="flex place-items-center rounded-tr-full rounded-br-full  gap-2"
+        on:click={handleStack}
+        disabled={selectedAssetIds.size !== 1}
+      >
+        <Icon path={mdiImageMultipleOutline} size="20" />{$t('stack')}
       </Button>
-    {:else}
-      <Button size="sm" color="red" class="flex place-items-center gap-2 py-3" on:click={handleResolve}>
-        <Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
-          ? $t('trash_all')
-          : $t('trash_count', { values: { count: trashCount } })}
-      </Button>
-    {/if}
+    </div>
   </div>
 </div>
 
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index 1149bc99b8..172b1b5d05 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -1116,6 +1116,8 @@
   "sort_title": "Title",
   "source": "Source",
   "stack": "Stack",
+  "stack_duplicates": "Stack duplicates",
+  "stack_select_one_photo": "Select one main photo for the stack",
   "stack_selected_photos": "Stack selected photos",
   "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
   "stacktrace": "Stacktrace",
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index 476d910523..a23c369009 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -324,7 +324,7 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo
   return ids;
 };
 
-export const stackAssets = async (assets: AssetResponseDto[]) => {
+export const stackAssets = async (assets: AssetResponseDto[], showNotification = true) => {
   if (assets.length < 2) {
     return false;
   }
@@ -362,16 +362,18 @@ export const stackAssets = async (assets: AssetResponseDto[]) => {
   parent.stack = parent.stack.concat(children, grandChildren);
   parent.stackCount = parent.stack.length + 1;
 
-  notificationController.show({
-    message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
-    type: NotificationType.Info,
-    button: {
-      text: $t('view_stack'),
-      onClick() {
-        return assetViewingStore.setAssetId(parent.id);
+  if (showNotification) {
+    notificationController.show({
+      message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
+      type: NotificationType.Info,
+      button: {
+        text: $t('view_stack'),
+        onClick() {
+          return assetViewingStore.setAssetId(parent.id);
+        },
       },
-    },
-  });
+    });
+  }
 
   return ids;
 };
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 3a9bfbea7f..34889261d5 100644
--- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -6,6 +6,7 @@
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
   import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
+  import type { AssetResponseDto } from '@immich/sdk';
   import { featureFlags } from '$lib/stores/server-config.store';
   import { handleError } from '$lib/utils/handle-error';
   import { deleteAssets, updateAssets } from '@immich/sdk';
@@ -13,10 +14,11 @@
   import type { PageData } from './$types';
   import { suggestDuplicateByFileSize } from '$lib/utils';
   import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
+  import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js';
+  import { stackAssets } from '$lib/utils/asset-utils';
   import ShowShortcuts from '$lib/components/shared-components/show-shortcuts.svelte';
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
   import { mdiKeyboard } from '@mdi/js';
-  import { mdiCheckOutline, mdiTrashCanOutline } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import { locale } from '$lib/stores/preferences.store';
 
@@ -40,6 +42,7 @@
       { key: ['s'], action: $t('view') },
       { key: ['d'], action: $t('unselect_all_duplicates') },
       { key: ['⇧', 'c'], action: $t('resolve_duplicates') },
+      { key: ['⇧', 'c'], action: $t('stack_duplicates') },
     ],
   };
 
@@ -88,6 +91,13 @@
     );
   };
 
+  const handleStack = async (duplicateId: string, assets: AssetResponseDto[]) => {
+    await stackAssets(assets, false);
+    const duplicateAssetIds = assets.map((asset) => asset.id);
+    await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
+    data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
+  };
+
   const handleDeduplicateAll = async () => {
     const idsToKeep = data.duplicates
       .map((group) => suggestDuplicateByFileSize(group.assets))
@@ -174,6 +184,7 @@
           assets={data.duplicates[0].assets}
           onResolve={(duplicateAssetIds, trashIds) =>
             handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
+          onStack={(assets) => handleStack(data.duplicates[0].duplicateId, assets)}
         />
       {/key}
     {:else}