mirror of
https://github.com/immich-app/immich.git
synced 2025-10-30 10:12:33 -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