mirror of
https://github.com/immich-app/immich.git
synced 2025-11-02 10:37:11 -05: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",
|
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"dark_theme": "Toggle dark theme",
|
"dark_theme": "Toggle dark theme",
|
||||||
|
"date": "Date",
|
||||||
"date_after": "Date after",
|
"date_after": "Date after",
|
||||||
"date_and_time": "Date and Time",
|
"date_and_time": "Date and Time",
|
||||||
"date_before": "Date before",
|
"date_before": "Date before",
|
||||||
@ -1100,6 +1101,7 @@
|
|||||||
"features_setting_description": "Manage the app features",
|
"features_setting_description": "Manage the app features",
|
||||||
"file_name": "File name",
|
"file_name": "File name",
|
||||||
"file_name_or_extension": "File name or extension",
|
"file_name_or_extension": "File name or extension",
|
||||||
|
"file_size": "File size",
|
||||||
"filename": "Filename",
|
"filename": "Filename",
|
||||||
"filetype": "Filetype",
|
"filetype": "Filetype",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
@ -1263,6 +1265,7 @@
|
|||||||
"local_media_summary": "Local Media Summary",
|
"local_media_summary": "Local Media Summary",
|
||||||
"local_network": "Local network",
|
"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",
|
"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": "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_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",
|
"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_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_sqlite_success": "Successfully reset the SQLite database",
|
||||||
"reset_to_default": "Reset to default",
|
"reset_to_default": "Reset to default",
|
||||||
|
"resolution": "Resolution",
|
||||||
"resolve_duplicates": "Resolve duplicates",
|
"resolve_duplicates": "Resolve duplicates",
|
||||||
"resolved_all_duplicates": "Resolved all duplicates",
|
"resolved_all_duplicates": "Resolved all duplicates",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
@ -2021,6 +2025,7 @@
|
|||||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||||
"they_will_be_merged_together": "They will be merged together",
|
"they_will_be_merged_together": "They will be merged together",
|
||||||
"third_party_resources": "Third-Party Resources",
|
"third_party_resources": "Third-Party Resources",
|
||||||
|
"time": "Time",
|
||||||
"time_based_memories": "Time-based memories",
|
"time_based_memories": "Time-based memories",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
"timezone": "Timezone",
|
"timezone": "Timezone",
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
import {
|
import {
|
||||||
mdiBookmarkOutline,
|
mdiBookmarkOutline,
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
|
mdiClock,
|
||||||
|
mdiFile,
|
||||||
|
mdiFitToScreen,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiImageMultipleOutline,
|
mdiImageMultipleOutline,
|
||||||
mdiImageOutline,
|
mdiImageOutline,
|
||||||
@ -16,15 +19,17 @@
|
|||||||
mdiMapMarkerOutline,
|
mdiMapMarkerOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import InfoRow from './info-row.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
assets: AssetResponseDto[];
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelectAsset: (asset: AssetResponseDto) => void;
|
onSelectAsset: (asset: AssetResponseDto) => void;
|
||||||
onViewAsset: (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 isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||||
@ -37,13 +42,46 @@
|
|||||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||||
: fromISODateTimeUTC(asset.localDateTime),
|
: 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="min-w-60 transition-colors border rounded-lg">
|
||||||
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="relative w-full">
|
<div class="relative w-full">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -57,7 +95,7 @@
|
|||||||
src={getAssetThumbnailUrl(asset.id)}
|
src={getAssetThumbnailUrl(asset.id)}
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
title={assetData}
|
title={assetData}
|
||||||
class="h-60 object-cover rounded-t-xl w-full"
|
class="h-60 object-cover w-full rounded-t-md"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -106,19 +144,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="grid place-items-start gap-y-2 py-2 text-xs transition-colors {isSelected
|
class="grid place-items-start gap-y-2 py-2 text-sm transition-colors rounded-b-lg {isSelected
|
||||||
? 'text-white dark:text-black'
|
? 'bg-success/15 dark:bg-[#001a06]'
|
||||||
: 'dark:text-white'}"
|
: 'bg-transparent'}"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-x-1">
|
<InfoRow icon={mdiImageOutline} highlight={hasDifferentValues.fileName} title={$t('file_name')}>
|
||||||
<Icon icon={mdiImageOutline} size="16" />
|
{asset.originalFileName}
|
||||||
<div>
|
</InfoRow>
|
||||||
<span class="break-all text-center">{asset.originalFileName}</span><br />
|
|
||||||
{getAssetResolution(asset)} - {getFileSize(asset)}
|
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
|
||||||
</div>
|
{getFileSize(asset)}
|
||||||
</div>
|
</InfoRow>
|
||||||
<div class="flex items-start gap-x-1">
|
|
||||||
<Icon icon={mdiCalendar} size="16" />
|
<InfoRow icon={mdiFitToScreen} highlight={hasDifferentValues.resolution} title={$t('resolution')}>
|
||||||
|
{getAssetResolution(asset)}
|
||||||
|
</InfoRow>
|
||||||
|
|
||||||
|
<InfoRow icon={mdiCalendar} highlight={hasDifferentValues.date} title={$t('date')}>
|
||||||
{#if dateTime}
|
{#if dateTime}
|
||||||
{dateTime.toLocaleString(
|
{dateTime.toLocaleString(
|
||||||
{
|
{
|
||||||
@ -128,12 +170,18 @@
|
|||||||
},
|
},
|
||||||
{ locale: $locale },
|
{ locale: $locale },
|
||||||
)}
|
)}
|
||||||
|
{:else}
|
||||||
|
{$t('unknown')}
|
||||||
|
{/if}
|
||||||
|
</InfoRow>
|
||||||
|
|
||||||
|
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
|
||||||
|
{#if dateTime}
|
||||||
{dateTime.toLocaleString(
|
{dateTime.toLocaleString(
|
||||||
{
|
{
|
||||||
// weekday: 'short',
|
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
timeZoneName: timeZone ? 'shortOffset' : undefined,
|
timeZoneName: timeZone ? 'shortOffset' : undefined,
|
||||||
},
|
},
|
||||||
{ locale: $locale },
|
{ locale: $locale },
|
||||||
@ -141,27 +189,22 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{$t('unknown')}
|
{$t('unknown')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</InfoRow>
|
||||||
|
|
||||||
<div class="flex items-start gap-x-1">
|
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
|
||||||
<Icon icon={mdiMapMarkerOutline} size="16" />
|
|
||||||
{#if locationParts.length > 0}
|
{#if locationParts.length > 0}
|
||||||
{locationParts.join(', ')}
|
{locationParts.join(', ')}
|
||||||
{:else}
|
{:else}
|
||||||
{$t('unknown')}
|
{$t('unknown')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</InfoRow>
|
||||||
<div class="flex items-start gap-x-1">
|
|
||||||
<Icon icon={mdiBookmarkOutline} size="16" />
|
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
|
||||||
{#await getAllAlbums({ assetId: asset.id })}
|
{#await getAllAlbums({ assetId: asset.id })}
|
||||||
{$t('scanning_for_album')}
|
{$t('scanning_for_album')}
|
||||||
{:then albums}
|
{:then albums}
|
||||||
{#if albums.length === 0}
|
{$t('in_albums', { values: { count: albums.length } })}
|
||||||
{$t('not_in_any_album')}
|
|
||||||
{:else}
|
|
||||||
{$t('in_albums', { values: { count: albums.length } })}
|
|
||||||
{/if}
|
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</InfoRow>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex flex-wrap gap-y-6 mb-4 px-6 w-full place-content-end justify-between">
|
||||||
<!-- MARK ALL BUTTONS -->
|
<!-- MARK ALL BUTTONS -->
|
||||||
<div class="flex text-xs text-black">
|
<div class="flex text-xs text-black">
|
||||||
@ -139,7 +139,7 @@
|
|||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
leadingIcon={mdiCheck}
|
leadingIcon={mdiCheck}
|
||||||
color="primary"
|
color="success"
|
||||||
class="flex place-items-center rounded-s-full gap-2"
|
class="flex place-items-center rounded-s-full gap-2"
|
||||||
onclick={handleResolve}
|
onclick={handleResolve}
|
||||||
>
|
>
|
||||||
@ -169,10 +169,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1 mb-4 place-items-center place-content-center px-4 pt-4">
|
<div class="overflow-x-auto p-2">
|
||||||
{#each assets as asset (asset.id)}
|
<div class="flex flex-nowrap gap-1 place-items-center justify-center min-w-full w-fit mx-auto">
|
||||||
<DuplicateAsset {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
|
{#each assets as asset (asset.id)}
|
||||||
{/each}
|
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</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)}
|
handleResolve(duplicates[duplicatesIndex].duplicateId, duplicateAssetIds, trashIds)}
|
||||||
onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)}
|
onStack={(assets) => handleStack(duplicates[duplicatesIndex].duplicateId, assets)}
|
||||||
/>
|
/>
|
||||||
<div class="max-w-216 mx-auto mb-16">
|
<div class="max-w-256 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="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">
|
<div class="flex text-xs text-black">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@ -305,7 +305,9 @@
|
|||||||
{$t('previous')}
|
{$t('previous')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div class="flex text-xs text-black">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user