From 698531d6e00d805ec2a6701e65c9dd8ae8457df3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Oct 2025 16:32:52 -0500 Subject: [PATCH] feat: improve UI for resolving duplication detection (#23145) * feat: improve UI for resolving duplication detection * pr feedback --- i18n/en.json | 5 + .../duplicates/duplicate-asset.svelte | 107 ++++++++++++------ .../duplicates-compare-control.svelte | 14 ++- .../utilities-page/duplicates/info-row.svelte | 23 ++++ .../[[assetId=id]]/+page.svelte | 8 +- 5 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 web/src/lib/components/utilities-page/duplicates/info-row.svelte diff --git a/i18n/en.json b/i18n/en.json index 13df73c03c..d0a4da3de5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", 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 9b2ee94cc9..8a8395d792 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -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', + ), + }); -
+
-
- -
- {asset.originalFileName}
- {getAssetResolution(asset)} - {getFileSize(asset)} -
-
-
- + + {asset.originalFileName} + + + + {getFileSize(asset)} + + + + {getAssetResolution(asset)} + + + {#if dateTime} {dateTime.toLocaleString( { @@ -128,12 +170,18 @@ }, { locale: $locale }, )} + {:else} + {$t('unknown')} + {/if} + + + {#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} -
+ -
- + {#if locationParts.length > 0} {locationParts.join(', ')} {:else} {$t('unknown')} {/if} -
-
- + + + {#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} -
+
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 5ec0837423..3509f07fb0 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 @@ -117,7 +117,7 @@ ]} /> -
+
@@ -139,7 +139,7 @@
-
- {#each assets as asset (asset.id)} - - {/each} +
+
+ {#each assets as asset (asset.id)} + + {/each} +
diff --git a/web/src/lib/components/utilities-page/duplicates/info-row.svelte b/web/src/lib/components/utilities-page/duplicates/info-row.svelte new file mode 100644 index 0000000000..e237d70feb --- /dev/null +++ b/web/src/lib/components/utilities-page/duplicates/info-row.svelte @@ -0,0 +1,23 @@ + + +
+ +
+ + {@render children?.()} + +
+
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 adc0f679cb..19f254a8cd 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 @@ -281,8 +281,8 @@ handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)} onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)} /> -
-
+
+
-

{duplicatesIndex + 1}/{duplicates.length.toLocaleString($locale)}

+

+ {duplicatesIndex + 1} / {duplicates.length.toLocaleString($locale)} +