feat(web): Skip duplicates (#20880)

* - add skip button to duplicates-compare-control

* - cleanup

* - change to next/previous
- move buttons to duplicates page, intead of compareControl
- add param based control/position

* - remove index param on keep/dedupe all

* - cleanup

* - cleanup index corrections

* - add left/right arrow keyboard shortcuts for previous/next
- cleanup

* - cleanup
This commit is contained in:
xCJPECKOVERx 2025-08-18 19:11:53 -04:00 committed by GitHub
parent 257b0c74af
commit a313e4338e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 113 additions and 6 deletions

View File

@ -1056,6 +1056,7 @@
"filter_people": "Filter people",
"filter_places": "Filter places",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
"folder": "Folder",
"folder_not_found": "Folder not found",
@ -1177,6 +1178,7 @@
"language_search_hint": "Search languages...",
"language_setting_description": "Select your preferred language",
"large_files": "Large Files",
"last": "Last",
"last_seen": "Last seen",
"latest_version": "Latest Version",
"latitude": "Latitude",

View File

@ -112,7 +112,7 @@
]}
/>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-16">
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-216 mx-auto mb-4">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<!-- MARK ALL BUTTONS -->
<div class="flex text-xs text-black">

View File

@ -1,10 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcuts } from '$lib/actions/shortcut';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import { AppRoute } from '$lib/constants';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { locale } from '$lib/stores/preferences.store';
@ -15,7 +19,16 @@
import type { AssetResponseDto } from '@immich/sdk';
import { deleteAssets, deleteDuplicates, updateAssets } from '@immich/sdk';
import { Button, HStack, IconButton, modalManager, Text } from '@immich/ui';
import { mdiCheckOutline, mdiInformationOutline, mdiKeyboard, mdiTrashCanOutline } from '@mdi/js';
import {
mdiCheckOutline,
mdiChevronLeft,
mdiChevronRight,
mdiInformationOutline,
mdiKeyboard,
mdiPageFirst,
mdiPageLast,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -47,6 +60,19 @@
};
let duplicates = $state(data.duplicates);
const correctDuplicatesIndex = (index: number) => {
return Math.max(0, Math.min(index, duplicates.length - 1));
};
let duplicatesIndex = $derived(
(() => {
const indexParam = page.url.searchParams.get('index') ?? '0';
const parsedIndex = Number.parseInt(indexParam, 10);
return correctDuplicatesIndex(Number.isNaN(parsedIndex) ? 0 : parsedIndex);
})(),
);
let hasDuplicates = $derived(duplicates.length > 0);
const withConfirmation = async (callback: () => Promise<void>, prompt?: string, confirmText?: string) => {
if (prompt && confirmText) {
@ -85,6 +111,7 @@
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
deletedNotification(trashIds.length);
await correctDuplicatesIndexAndGo(duplicatesIndex);
},
trashIds.length > 0 && !$featureFlags.trash ? $t('delete_duplicates_confirmation') : undefined,
trashIds.length > 0 && !$featureFlags.trash ? $t('permanently_delete') : undefined,
@ -96,6 +123,7 @@
const duplicateAssetIds = assets.map((asset) => asset.id);
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
duplicates = duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
await correctDuplicatesIndexAndGo(duplicatesIndex);
};
const handleDeduplicateAll = async () => {
@ -126,6 +154,9 @@
duplicates = [];
deletedNotification(idsToDelete.length);
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
prompt,
confirmText,
@ -144,13 +175,39 @@
message: $t('resolved_all_duplicates'),
type: NotificationType.Info,
});
page.url.searchParams.delete('index');
await goto(`${AppRoute.DUPLICATES}`);
},
$t('bulk_keep_duplicates_confirmation', { values: { count: ids.length } }),
$t('confirm'),
);
};
const handleFirst = async () => {
await correctDuplicatesIndexAndGo(0);
};
const handlePrevious = async () => {
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
};
const handleNext = async () => {
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
};
const handleLast = async () => {
await correctDuplicatesIndexAndGo(duplicates.length - 1);
};
const correctDuplicatesIndexAndGo = async (index: number) => {
page.url.searchParams.set('index', correctDuplicatesIndex(index).toString());
await goto(`${AppRoute.DUPLICATES}?${page.url.searchParams.toString()}`);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
{#snippet buttons()}
<HStack gap={0}>
@ -203,13 +260,61 @@
/>
</div>
{#key duplicates[0].duplicateId}
{#key duplicates[duplicatesIndex].duplicateId}
<DuplicatesCompareControl
assets={duplicates[0].assets}
assets={duplicates[duplicatesIndex].assets}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[0].duplicateId, assets)}
handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)}
onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)}
/>
<div class="max-w-216 mx-auto mb-16">
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
<div class="flex text-xs text-black">
<Button
size="small"
leadingIcon={mdiPageFirst}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleFirst}
disabled={duplicatesIndex === 0}
>
{$t('first')}
</Button>
<Button
size="small"
leadingIcon={mdiChevronLeft}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handlePrevious}
disabled={duplicatesIndex === 0}
>
{$t('previous')}
</Button>
</div>
<div class="flex text-xs text-black">
<Button
size="small"
trailingIcon={mdiChevronRight}
color="primary"
class="flex place-items-center rounded-s-full gap-2 px-2 sm:px-4"
onclick={handleNext}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('next')}
</Button>
<Button
size="small"
trailingIcon={mdiPageLast}
color="primary"
class="flex place-items-center rounded-e-full gap-2 px-2 sm:px-4"
onclick={handleLast}
disabled={duplicatesIndex === duplicates.length - 1}
>
{$t('last')}
</Button>
</div>
</div>
</div>
{/key}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">