feat: improve UI for resolving duplication detection (#23145)

* feat: improve UI for resolving duplication detection

* pr feedback
This commit is contained in:
Alex 2025-10-27 16:32:52 -05:00 committed by GitHub
parent 44149d187f
commit 698531d6e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 116 additions and 41 deletions

View File

@ -791,6 +791,7 @@
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Toggle dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
"date_before": "Date before",
@ -1100,6 +1101,7 @@
"features_setting_description": "Manage the app features",
"file_name": "File name",
"file_name_or_extension": "File name or extension",
"file_size": "File size",
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
@ -1263,6 +1265,7 @@
"local_media_summary": "Local Media Summary",
"local_network": "Local network",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
"location": "Location",
"location_permission": "Location permission",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current Wi-Fi network's name",
"location_picker_choose_on_map": "Choose on map",
@ -1695,6 +1698,7 @@
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_success": "Successfully reset the SQLite database",
"reset_to_default": "Reset to default",
"resolution": "Resolution",
"resolve_duplicates": "Resolve duplicates",
"resolved_all_duplicates": "Resolved all duplicates",
"restore": "Restore",
@ -2021,6 +2025,7 @@
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
"they_will_be_merged_together": "They will be merged together",
"third_party_resources": "Third-Party Resources",
"time": "Time",
"time_based_memories": "Time-based memories",
"timeline": "Timeline",
"timezone": "Timezone",

View File

@ -9,6 +9,9 @@
import {
mdiBookmarkOutline,
mdiCalendar,
mdiClock,
mdiFile,
mdiFitToScreen,
mdiHeart,
mdiImageMultipleOutline,
mdiImageOutline,
@ -16,15 +19,17 @@
mdiMapMarkerOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import InfoRow from './info-row.svelte';
interface Props {
assets: AssetResponseDto[];
asset: AssetResponseDto;
isSelected: boolean;
onSelectAsset: (asset: AssetResponseDto) => void;
onViewAsset: (asset: AssetResponseDto) => void;
}
let { asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
let isFromExternalLibrary = $derived(!!asset.libraryId);
let assetData = $derived(JSON.stringify(asset, null, 2));
@ -37,13 +42,46 @@
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const isDifferent = (getter: (asset: AssetResponseDto) => string | undefined): boolean => {
return new Set(assets.map((asset) => getter(asset))).size > 1;
};
const hasDifferentValues = $derived({
fileName: isDifferent((a) => a.originalFileName),
fileSize: isDifferent((a) => getFileSize(a)),
resolution: isDifferent((a) => getAssetResolution(a)),
date: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale: $locale });
}),
time: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
tz && a.exifInfo?.dateTimeOriginal
? fromISODateTime(a.exifInfo.dateTimeOriginal, tz)
: fromISODateTimeUTC(a.localDateTime);
return dt?.toLocaleString(
{
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: tz ? 'shortOffset' : undefined,
},
{ locale: $locale },
);
}),
location: isDifferent(
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
),
});
</script>
<div
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs {isSelected
? 'bg-primary border-primary'
: 'bg-subtle border-subtle'}"
>
<div class="min-w-60 transition-colors border rounded-lg">
<div class="relative w-full">
<button
type="button"
@ -57,7 +95,7 @@
src={getAssetThumbnailUrl(asset.id)}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover rounded-t-xl w-full"
class="h-60 object-cover w-full rounded-t-md"
draggable="false"
/>
@ -106,19 +144,23 @@
</div>
<div
class="grid place-items-start gap-y-2 py-2 text-xs transition-colors {isSelected
? 'text-white dark:text-black'
: 'dark:text-white'}"
class="grid place-items-start gap-y-2 py-2 text-sm transition-colors rounded-b-lg {isSelected
? 'bg-success/15 dark:bg-[#001a06]'
: 'bg-transparent'}"
>
<div class="flex items-start gap-x-1">
<Icon icon={mdiImageOutline} size="16" />
<div>
<span class="break-all text-center">{asset.originalFileName}</span><br />
{getAssetResolution(asset)} - {getFileSize(asset)}
</div>
</div>
<div class="flex items-start gap-x-1">
<Icon icon={mdiCalendar} size="16" />
<InfoRow icon={mdiImageOutline} highlight={hasDifferentValues.fileName} title={$t('file_name')}>
{asset.originalFileName}
</InfoRow>
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
{getFileSize(asset)}
</InfoRow>
<InfoRow icon={mdiFitToScreen} highlight={hasDifferentValues.resolution} title={$t('resolution')}>
{getAssetResolution(asset)}
</InfoRow>
<InfoRow icon={mdiCalendar} highlight={hasDifferentValues.date} title={$t('date')}>
{#if dateTime}
{dateTime.toLocaleString(
{
@ -128,12 +170,18 @@
},
{ locale: $locale },
)}
{:else}
{$t('unknown')}
{/if}
</InfoRow>
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
{#if dateTime}
{dateTime.toLocaleString(
{
// weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
timeZoneName: timeZone ? 'shortOffset' : undefined,
},
{ locale: $locale },
@ -141,27 +189,22 @@
{:else}
{$t('unknown')}
{/if}
</div>
</InfoRow>
<div class="flex items-start gap-x-1">
<Icon icon={mdiMapMarkerOutline} size="16" />
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
{#if locationParts.length > 0}
{locationParts.join(', ')}
{:else}
{$t('unknown')}
{/if}
</div>
<div class="flex items-start gap-x-1">
<Icon icon={mdiBookmarkOutline} size="16" />
</InfoRow>
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
{#await getAllAlbums({ assetId: asset.id })}
{$t('scanning_for_album')}
{:then albums}
{#if albums.length === 0}
{$t('not_in_any_album')}
{:else}
{$t('in_albums', { values: { count: albums.length } })}
{/if}
{$t('in_albums', { values: { count: albums.length } })}
{/await}
</div>
</InfoRow>
</div>
</div>

View File

@ -117,7 +117,7 @@
]}
/>
<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="rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-256 mx-auto mb-4 py-6 px-0.2">
<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">
@ -139,7 +139,7 @@
<Button
size="small"
leadingIcon={mdiCheck}
color="primary"
color="success"
class="flex place-items-center rounded-s-full gap-2"
onclick={handleResolve}
>
@ -169,10 +169,12 @@
</div>
</div>
<div class="flex flex-wrap gap-1 mb-4 place-items-center place-content-center px-4 pt-4">
{#each assets as asset (asset.id)}
<DuplicateAsset {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
{/each}
<div class="overflow-x-auto p-2">
<div class="flex flex-nowrap gap-1 place-items-center justify-center min-w-full w-fit mx-auto">
{#each assets as asset (asset.id)}
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { Icon, Text } from '@immich/ui';
import type { Snippet } from 'svelte';
interface Props {
icon: string;
children?: Snippet;
borderBottom?: boolean;
highlight?: boolean;
title?: string;
}
let { icon, children, borderBottom = true, highlight = false, title }: Props = $props();
</script>
<div class="grid grid-cols-[25px_1fr] w-full px-1 py-0.5" class:border-b={borderBottom} {title}>
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
<div class="justify-self-end text-end rounded px-1 transition-colors">
<Text size="tiny" class={highlight ? 'font-semibold text-primary' : ''}>
{@render children?.()}
</Text>
</div>
</div>

View File

@ -281,8 +281,8 @@
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 items-center">
<div class="max-w-256 mx-auto mb-16">
<div class="flex mb-4 sm:px-6 w-full place-content-center justify-between items-center place-items-center">
<div class="flex text-xs text-black">
<Button
size="small"
@ -305,7 +305,9 @@
{$t('previous')}
</Button>
</div>
<p>{duplicatesIndex + 1}/{duplicates.length.toLocaleString($locale)}</p>
<p class="border px-3 md:px-6 py-1 dark:bg-subtle rounded-lg text-xs md:text-sm">
{duplicatesIndex + 1} / {duplicates.length.toLocaleString($locale)}
</p>
<div class="flex text-xs text-black">
<Button
size="small"