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}