mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	feat: improve UI for resolving duplication detection (#23145)
* feat: improve UI for resolving duplication detection * pr feedback
This commit is contained in:
		
							parent
							
								
									44149d187f
								
							
						
					
					
						commit
						698531d6e0
					
				| @ -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", | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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> | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
| @ -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" | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user