feat: Selectable metadata in duplicates utility with diffing (#26328)
parent
3d075f2bf8
commit
0544d22902
11
i18n/en.json
11
i18n/en.json
|
|
@ -897,6 +897,7 @@
|
|||
"date_of_birth": "Date of birth",
|
||||
"date_of_birth_saved": "Date of birth saved successfully",
|
||||
"date_range": "Date range",
|
||||
"date_time_original": "Date/Time Original",
|
||||
"day": "Day",
|
||||
"days": "Days",
|
||||
"deduplicate_all": "Deduplicate All",
|
||||
|
|
@ -1197,11 +1198,13 @@
|
|||
"export_as_json": "Export as JSON",
|
||||
"export_database": "Export Database",
|
||||
"export_database_description": "Export the SQLite database",
|
||||
"exposure_time": "Exposure Time",
|
||||
"extension": "Extension",
|
||||
"external": "External",
|
||||
"external_libraries": "External Libraries",
|
||||
"external_network": "External network",
|
||||
"external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom",
|
||||
"f_number": "F-Number",
|
||||
"face_unassigned": "Unassigned",
|
||||
"failed": "Failed",
|
||||
"failed_count": "Failed: {count}",
|
||||
|
|
@ -1219,7 +1222,6 @@
|
|||
"features_setting_description": "Manage the app features",
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_name_text": "File name",
|
||||
"file_name_with_value": "File name: {file_name}",
|
||||
"file_size": "File size",
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
|
|
@ -1232,6 +1234,7 @@
|
|||
"find_them_fast": "Find them fast by name with search",
|
||||
"first": "First",
|
||||
"fix_incorrect_match": "Fix incorrect match",
|
||||
"focal_length": "Focal Length",
|
||||
"folder": "Folder",
|
||||
"folder_not_found": "Folder not found",
|
||||
"folders": "Folders",
|
||||
|
|
@ -1352,6 +1355,7 @@
|
|||
"ios_debug_info_no_sync_yet": "No background sync job has run yet",
|
||||
"ios_debug_info_processes_queued": "{count, plural, one {{count} background process queued} other {{count} background processes queued}}",
|
||||
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
|
||||
"iso": "ISO",
|
||||
"items_count": "{count, plural, one {# item} other {# items}}",
|
||||
"jobs": "Jobs",
|
||||
"json_editor": "JSON editor",
|
||||
|
|
@ -1584,6 +1588,7 @@
|
|||
"mobile_app": "Mobile App",
|
||||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||
"model": "Model",
|
||||
"modify_date": "Modify Date",
|
||||
"month": "Month",
|
||||
"more": "More",
|
||||
"motion": "Motion",
|
||||
|
|
@ -1706,6 +1711,7 @@
|
|||
"organize_into_albums": "Organize into albums",
|
||||
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
|
||||
"organize_your_library": "Organize your library",
|
||||
"orientation": "Orientation",
|
||||
"original": "original",
|
||||
"other": "Other",
|
||||
"other_devices": "Other devices",
|
||||
|
|
@ -1820,6 +1826,7 @@
|
|||
"profile_drawer_readonly_mode": "Read-only mode enabled. Long-press the user avatar icon to exit.",
|
||||
"profile_image_of_user": "Profile image of {user}",
|
||||
"profile_picture_set": "Profile picture set.",
|
||||
"projection_type": "Projection Type",
|
||||
"public_album": "Public album",
|
||||
"public_share": "Public Share",
|
||||
"purchase_account_info": "Supporter",
|
||||
|
|
@ -2189,7 +2196,9 @@
|
|||
"show_in_timeline": "Show in timeline",
|
||||
"show_in_timeline_setting_description": "Show photos and videos from this user in your timeline",
|
||||
"show_keyboard_shortcuts": "Show keyboard shortcuts",
|
||||
"show_less": "Show less",
|
||||
"show_metadata": "Show metadata",
|
||||
"show_more_fields": "{count, plural, one {Show # more field} other {Show # more fields}}",
|
||||
"show_or_hide_info": "Show or hide info",
|
||||
"show_password": "Show password",
|
||||
"show_person_options": "Show person options",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiBrightness6,
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
mdiCameraIris,
|
||||
mdiCameraOutline,
|
||||
mdiClockEditOutline,
|
||||
mdiCrosshairsGps,
|
||||
mdiEarth,
|
||||
mdiFileClockOutline,
|
||||
mdiFileEditOutline,
|
||||
mdiFileImageOutline,
|
||||
mdiFitToScreen,
|
||||
mdiFolderOutline,
|
||||
mdiMapMarkerOutline,
|
||||
mdiPanorama,
|
||||
mdiPhoneRotateLandscape,
|
||||
mdiRayStartArrow,
|
||||
mdiStarOutline,
|
||||
mdiTextBox,
|
||||
mdiTimerOutline,
|
||||
mdiWeightKilogram,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
||||
|
||||
const truncateMiddle = (path: string, maxLength = 50): string => {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
const tail = lastSlash === -1 ? path : path.slice(lastSlash);
|
||||
|
||||
if (tail.length >= maxLength - 3) {
|
||||
const half = Math.floor((maxLength - 3) / 2);
|
||||
return path.slice(0, half) + '...' + path.slice(-half);
|
||||
}
|
||||
|
||||
const headLength = maxLength - 3 - tail.length;
|
||||
return path.slice(0, headLength) + '...' + tail;
|
||||
};
|
||||
|
||||
const formatISODateToLocale = (iso: string, locale: string | undefined): string =>
|
||||
fromISODateTimeUTC(iso).toLocaleString({ month: 'short', day: 'numeric', year: 'numeric' }, { locale });
|
||||
|
||||
const getDateTime = (asset: AssetResponseDto) => {
|
||||
const timeZone = asset.exifInfo?.timeZone;
|
||||
return timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime);
|
||||
};
|
||||
|
||||
type MetadataFieldDefinition = {
|
||||
icon: string;
|
||||
titleKey: string;
|
||||
keys: readonly string[];
|
||||
render: (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) => string;
|
||||
};
|
||||
|
||||
const metadataFields = [
|
||||
{
|
||||
icon: mdiFileImageOutline,
|
||||
titleKey: 'file_name_text',
|
||||
keys: ['originalFileName'],
|
||||
render: (asset, $t) => asset.originalFileName || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiFolderOutline,
|
||||
titleKey: 'path',
|
||||
keys: ['originalPath'],
|
||||
render: (asset, $t) => truncateMiddle(asset.originalPath) || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiWeightKilogram,
|
||||
titleKey: 'file_size',
|
||||
keys: ['fileSize'],
|
||||
render: (asset) => getFileSize(asset),
|
||||
},
|
||||
{
|
||||
icon: mdiFitToScreen,
|
||||
titleKey: 'resolution',
|
||||
keys: ['resolution'],
|
||||
render: (asset, $t) => getAssetResolution(asset) || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiFileClockOutline,
|
||||
titleKey: 'created_at',
|
||||
keys: ['fileCreatedAt'],
|
||||
render: (asset, _t, locale) => formatISODateToLocale(asset.fileCreatedAt, locale),
|
||||
},
|
||||
{
|
||||
icon: mdiFileEditOutline,
|
||||
titleKey: 'updated_at',
|
||||
keys: ['fileModifiedAt'],
|
||||
render: (asset, _t, locale) => formatISODateToLocale(asset.fileModifiedAt, locale),
|
||||
},
|
||||
{
|
||||
icon: mdiCalendar,
|
||||
titleKey: 'date_time_original',
|
||||
keys: ['dateTimeOriginal'],
|
||||
render: (asset, $t, locale) => {
|
||||
const dateTime = getDateTime(asset);
|
||||
return dateTime
|
||||
? dateTime.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'shortOffset',
|
||||
},
|
||||
{ locale },
|
||||
)
|
||||
: $t('unknown');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: mdiEarth,
|
||||
titleKey: 'timezone',
|
||||
keys: ['timeZone'],
|
||||
render: (asset, $t) => getDateTime(asset)?.offsetNameShort ?? $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiClockEditOutline,
|
||||
titleKey: 'modify_date',
|
||||
keys: ['modifyDate'],
|
||||
render: (asset, $t, locale) =>
|
||||
asset.exifInfo?.modifyDate ? formatISODateToLocale(asset.exifInfo.modifyDate, locale) : $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiMapMarkerOutline,
|
||||
titleKey: 'location',
|
||||
keys: ['city', 'state', 'country'],
|
||||
render: (asset, $t) => {
|
||||
const parts = [asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(', ') : $t('unknown');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: mdiCrosshairsGps,
|
||||
titleKey: 'gps',
|
||||
keys: ['latitude', 'longitude'],
|
||||
render: (asset, $t) =>
|
||||
asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null
|
||||
? `${asset.exifInfo.latitude.toFixed(4)}, ${asset.exifInfo.longitude.toFixed(4)}`
|
||||
: $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiCameraOutline,
|
||||
titleKey: 'make',
|
||||
keys: ['make'],
|
||||
render: (asset, $t) => asset.exifInfo?.make || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiCamera,
|
||||
titleKey: 'model',
|
||||
keys: ['model'],
|
||||
render: (asset, $t) => asset.exifInfo?.model || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiCameraIris,
|
||||
titleKey: 'lens_model',
|
||||
keys: ['lensModel'],
|
||||
render: (asset, $t) => asset.exifInfo?.lensModel || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiCameraIris,
|
||||
titleKey: 'f_number',
|
||||
keys: ['fNumber'],
|
||||
render: (asset, $t) => (asset.exifInfo?.fNumber == null ? $t('unknown') : `f/${asset.exifInfo.fNumber.toFixed(1)}`),
|
||||
},
|
||||
{
|
||||
icon: mdiRayStartArrow,
|
||||
titleKey: 'focal_length',
|
||||
keys: ['focalLength'],
|
||||
render: (asset, $t) => (asset.exifInfo?.focalLength == null ? $t('unknown') : `${asset.exifInfo.focalLength} mm`),
|
||||
},
|
||||
{
|
||||
icon: mdiBrightness6,
|
||||
titleKey: 'iso',
|
||||
keys: ['iso'],
|
||||
render: (asset, $t) => (asset.exifInfo?.iso == null ? $t('unknown') : `ISO ${asset.exifInfo.iso}`),
|
||||
},
|
||||
{
|
||||
icon: mdiTimerOutline,
|
||||
titleKey: 'exposure_time',
|
||||
keys: ['exposureTime'],
|
||||
render: (asset, $t) => asset.exifInfo?.exposureTime || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiTextBox,
|
||||
titleKey: 'description',
|
||||
keys: ['description'],
|
||||
render: (asset, $t) => asset.exifInfo?.description || $t('unknown'),
|
||||
},
|
||||
{
|
||||
icon: mdiStarOutline,
|
||||
titleKey: 'rating',
|
||||
keys: ['rating'],
|
||||
render: (asset, $t) => (asset.exifInfo?.rating == null ? $t('unknown') : `${asset.exifInfo.rating} stars`),
|
||||
},
|
||||
{
|
||||
icon: mdiPhoneRotateLandscape,
|
||||
titleKey: 'orientation',
|
||||
keys: ['orientation'],
|
||||
render: (asset, $t) => String(asset.exifInfo?.orientation || $t('unknown')),
|
||||
},
|
||||
{
|
||||
icon: mdiPanorama,
|
||||
titleKey: 'projection_type',
|
||||
keys: ['projectionType'],
|
||||
render: (asset, $t) => asset.exifInfo?.projectionType || $t('unknown'),
|
||||
},
|
||||
] as const satisfies readonly MetadataFieldDefinition[];
|
||||
|
||||
export type MetadataFieldKey = (typeof metadataFields)[number]['keys'][number];
|
||||
export type DifferingMetadataFields = Partial<Record<MetadataFieldKey, boolean>>;
|
||||
|
||||
export const metadataKeys: readonly MetadataFieldKey[] = metadataFields.flatMap(({ keys }) => keys);
|
||||
|
||||
export const countDifferingMetadataItems = (differing: DifferingMetadataFields): number =>
|
||||
metadataFields.filter(({ keys }) => keys.some((k) => differing[k as MetadataFieldKey])).length;
|
||||
|
||||
export const getAllMetadataItems = (asset: AssetResponseDto, $t: MessageFormatter, locale: string | undefined) =>
|
||||
metadataFields.map(({ icon, titleKey, keys, render }) => ({
|
||||
icon,
|
||||
title: $t(titleKey),
|
||||
render: render(asset, $t, locale),
|
||||
keys,
|
||||
}));
|
||||
|
||||
const normalizeForComparison = (key: MetadataFieldKey, value: unknown): unknown => {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (key === 'fileCreatedAt' || key === 'fileModifiedAt' || key === 'dateTimeOriginal' || key === 'modifyDate') {
|
||||
const dateTime = DateTime.fromISO(String(value));
|
||||
return dateTime.isValid ? dateTime.toISO() : String(value);
|
||||
}
|
||||
|
||||
if (key === 'fNumber' && typeof value === 'number') {
|
||||
return Number(value.toFixed(1));
|
||||
}
|
||||
if ((key === 'latitude' || key === 'longitude') && typeof value === 'number') {
|
||||
return Number(value.toFixed(4));
|
||||
}
|
||||
if (key === 'focalLength' && typeof value === 'number') {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const getValueForAsset = (asset: AssetResponseDto, key: MetadataFieldKey): unknown => {
|
||||
switch (key) {
|
||||
case 'fileCreatedAt':
|
||||
case 'fileModifiedAt':
|
||||
case 'originalFileName':
|
||||
case 'originalPath': {
|
||||
return asset[key];
|
||||
}
|
||||
case 'fileSize': {
|
||||
return getFileSize(asset);
|
||||
}
|
||||
case 'resolution': {
|
||||
return getAssetResolution(asset);
|
||||
}
|
||||
default: {
|
||||
if (asset.exifInfo && key in asset.exifInfo) {
|
||||
return asset.exifInfo[key as keyof typeof asset.exifInfo];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const computeDifferingMetadataFields = (assets: AssetResponseDto[]): DifferingMetadataFields => {
|
||||
const diffs: DifferingMetadataFields = {};
|
||||
|
||||
for (const key of metadataKeys) {
|
||||
const uniqueValues = new Set<unknown>();
|
||||
|
||||
for (const asset of assets) {
|
||||
const value = getValueForAsset(asset, key);
|
||||
if (value !== undefined && value !== null) {
|
||||
uniqueValues.add(normalizeForComparison(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
diffs[key] = uniqueValues.size > 1;
|
||||
}
|
||||
|
||||
return diffs;
|
||||
};
|
||||
|
|
@ -1,103 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiBookmarkOutline,
|
||||
mdiCalendar,
|
||||
mdiClock,
|
||||
mdiFile,
|
||||
mdiFitToScreen,
|
||||
mdiFolderOutline,
|
||||
mdiHeart,
|
||||
mdiImageMultipleOutline,
|
||||
mdiImageOutline,
|
||||
mdiMagnifyPlus,
|
||||
mdiMapMarkerOutline,
|
||||
} from '@mdi/js';
|
||||
import { mdiBookmarkOutline, mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import InfoRow from './InfoRow.svelte';
|
||||
|
||||
interface Props {
|
||||
assets: AssetResponseDto[];
|
||||
asset: AssetResponseDto;
|
||||
isSelected: boolean;
|
||||
onSelectAsset: (asset: AssetResponseDto) => void;
|
||||
onViewAsset: (asset: AssetResponseDto) => void;
|
||||
differingMetadataFields: DifferingMetadataFields;
|
||||
showMore?: boolean;
|
||||
initialVisibleCount?: number;
|
||||
}
|
||||
|
||||
let { assets, asset, isSelected, onSelectAsset, onViewAsset }: Props = $props();
|
||||
let {
|
||||
asset,
|
||||
isSelected,
|
||||
onSelectAsset,
|
||||
onViewAsset,
|
||||
differingMetadataFields,
|
||||
showMore = false,
|
||||
initialVisibleCount = 5,
|
||||
}: Props = $props();
|
||||
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
|
||||
let locationParts = $derived([asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean));
|
||||
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
const visibleMetadataItems = $derived(
|
||||
getAllMetadataItems(asset, $t, $locale)
|
||||
.filter(({ keys }) => keys.some((k) => differingMetadataFields[k]))
|
||||
.slice(0, showMore ? undefined : initialVisibleCount),
|
||||
);
|
||||
|
||||
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)),
|
||||
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
|
||||
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',
|
||||
),
|
||||
});
|
||||
|
||||
const getBasePath = (fullpath: string, fileName: string): string => {
|
||||
if (fileName && fullpath.endsWith(fileName)) {
|
||||
return fullpath.slice(0, -(fileName.length + 1));
|
||||
}
|
||||
return fullpath;
|
||||
};
|
||||
|
||||
function truncateMiddle(path: string, maxLength: number = 50): string {
|
||||
if (path.length <= maxLength) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const start = Math.floor(maxLength / 2) - 2;
|
||||
const end = Math.floor(maxLength / 2) - 2;
|
||||
|
||||
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-60 flex-1 rounded-lg border transition-colors">
|
||||
|
|
@ -166,69 +105,13 @@
|
|||
? 'bg-success/15 dark:bg-[#001a06]'
|
||||
: 'bg-transparent'}"
|
||||
>
|
||||
<InfoRow
|
||||
icon={mdiImageOutline}
|
||||
highlight={hasDifferentValues.fileName}
|
||||
title={$t('file_name_with_value', { values: { file_name: asset.originalFileName ?? '' } })}
|
||||
>
|
||||
{asset.originalFileName}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow
|
||||
icon={mdiFolderOutline}
|
||||
highlight={hasDifferentValues.originalPath}
|
||||
title={$t('full_path', { values: { path: asset.originalPath } })}
|
||||
>
|
||||
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
|
||||
</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(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiClock} highlight={hasDifferentValues.time} title={$t('time')}>
|
||||
{#if dateTime}
|
||||
{dateTime.toLocaleString(
|
||||
{
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: timeZone ? 'shortOffset' : undefined,
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
</InfoRow>
|
||||
|
||||
<InfoRow icon={mdiMapMarkerOutline} highlight={hasDifferentValues.location} title={$t('location')}>
|
||||
{#if locationParts.length > 0}
|
||||
{locationParts.join(', ')}
|
||||
{:else}
|
||||
{$t('unknown')}
|
||||
{/if}
|
||||
{#each visibleMetadataItems as { icon, title, render, keys } (keys[0])}
|
||||
<InfoRow {icon} {title}>
|
||||
{render}
|
||||
</InfoRow>
|
||||
{/each}
|
||||
|
||||
<!-- Albums always shown -->
|
||||
<InfoRow icon={mdiBookmarkOutline} borderBottom={false} title={$t('albums')}>
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
{$t('scanning_for_album')}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||
import {
|
||||
computeDifferingMetadataFields,
|
||||
countDifferingMetadataItems,
|
||||
type DifferingMetadataFields,
|
||||
} from '$lib/utils/duplicate-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { mdiCheck, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiCheck, mdiChevronDown, mdiChevronUp, mdiImageMultipleOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
|
@ -26,6 +31,13 @@
|
|||
let selectedAssetIds = $state(new SvelteSet<string>());
|
||||
let trashCount = $derived(assets.length - selectedAssetIds.size);
|
||||
|
||||
const InitialVisibleCount = 5;
|
||||
|
||||
const differingMetadataFields: DifferingMetadataFields = $derived(computeDifferingMetadataFields(assets));
|
||||
const differingCount = $derived(countDifferingMetadataItems(differingMetadataFields));
|
||||
const hasMore = $derived(differingCount > InitialVisibleCount);
|
||||
let showMore = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (suggestedKeepAssetIds.length > 0) {
|
||||
for (const id of suggestedKeepAssetIds) {
|
||||
|
|
@ -160,10 +172,29 @@
|
|||
<div class="overflow-x-auto p-2">
|
||||
<div class="mx-auto flex w-fit min-w-full flex-nowrap place-items-start justify-center gap-1">
|
||||
{#each assets as asset (asset.id)}
|
||||
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
|
||||
<DuplicateAsset
|
||||
{asset}
|
||||
{onSelectAsset}
|
||||
isSelected={selectedAssetIds.has(asset.id)}
|
||||
{onViewAsset}
|
||||
{differingMetadataFields}
|
||||
{showMore}
|
||||
initialVisibleCount={InitialVisibleCount}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="flex justify-center pb-2">
|
||||
<Button size="small" variant="ghost" color="secondary" onclick={() => (showMore = !showMore)}>
|
||||
<Icon icon={showMore ? mdiChevronUp : mdiChevronDown} size="18" class="me-1" />
|
||||
{showMore
|
||||
? $t('show_less')
|
||||
: $t('show_more_fields', { values: { count: differingCount - InitialVisibleCount } })}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if assetViewerManager.isViewing}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,23 @@
|
|||
icon: string;
|
||||
children?: Snippet;
|
||||
borderBottom?: boolean;
|
||||
highlight?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { icon, children, borderBottom = true, highlight = false, title }: Props = $props();
|
||||
let { icon, children, borderBottom = true, title }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid w-full grid-cols-[25px_1fr] px-1 py-0.5" class:border-b={borderBottom} {title}>
|
||||
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
|
||||
<div class="w-full justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
|
||||
<Text
|
||||
size="tiny"
|
||||
fontWeight={highlight ? 'semi-bold' : 'normal'}
|
||||
class={`${highlight ? 'text-primary' : ''} w-full overflow-hidden text-ellipsis`}
|
||||
>
|
||||
<div class="grid w-full grid-cols-[20px_auto_1fr] overflow-hidden px-1 py-0.5" class:border-b={borderBottom} {title}>
|
||||
<Icon {icon} size="16" class="self-center text-dark/25" />
|
||||
|
||||
{#if title}
|
||||
<Text size="tiny" class="self-center truncate px-1 pr-2 text-immich-fg/40 dark:text-immich-dark-fg/40">
|
||||
{title}
|
||||
</Text>
|
||||
{/if}
|
||||
|
||||
<div class="justify-self-end overflow-hidden rounded-sm px-1 text-end transition-colors">
|
||||
<Text size="tiny" class="break-all">
|
||||
{@render children?.()}
|
||||
</Text>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue