mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 11:24:37 +02:00
feat(web): full screen view for duplicates (#10346)
* feat(web): full screen view for duplicates * styling: make button visibility better --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
6a5435764e
commit
f3c15c7df8
@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiMagnifyPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let isSelected: boolean;
|
||||
export let onSelectAsset: (asset: AssetResponseDto) => void;
|
||||
export let onViewAsset: (asset: AssetResponseDto) => void;
|
||||
|
||||
$: isFromExternalLibrary = !!asset.libraryId;
|
||||
$: assetData = JSON.stringify(asset, null, 2);
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onSelectAsset(asset)}
|
||||
class="block relative rounded-t-xl"
|
||||
aria-pressed={isSelected}
|
||||
aria-label={$t('keep')}
|
||||
>
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetThumbnailUrl(asset.id)}
|
||||
alt={getAltText(asset)}
|
||||
title={`${assetData}`}
|
||||
class={`size-60 object-cover rounded-t-xl border-4 border-b-0 border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- OVERLAY CHIP -->
|
||||
<div
|
||||
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
|
||||
>
|
||||
{isSelected ? $t('keep') : $t('to_trash')}
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL LIBRARY CHIP-->
|
||||
{#if isFromExternalLibrary}
|
||||
<div class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white">
|
||||
{$t('external')}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onViewAsset(asset)}
|
||||
class="absolute rounded-full bottom-1 left-2 text-gray-200 p-1.5 hover:text-white bg-black/35"
|
||||
title={$t('view')}
|
||||
>
|
||||
<Icon ariaLabel={$t('view')} path={mdiMagnifyPlus} flipped />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ASSET INFO-->
|
||||
<table
|
||||
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
|
||||
>
|
||||
<tr
|
||||
class={`h-8 ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
|
||||
>
|
||||
<td>{asset.originalFileName}</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
class={`h-8 ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
|
||||
>
|
||||
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
class={`h-8 ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
|
||||
>
|
||||
<td>
|
||||
{#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}
|
||||
{/await}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { s } from '$lib/utils';
|
||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||
import { sortBy } from 'lodash-es';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let duplicate: DuplicateResponseDto;
|
||||
export let assets: AssetResponseDto[];
|
||||
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
|
||||
|
||||
let selectedAssetIds = new Set<string>();
|
||||
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
|
||||
|
||||
$: trashCount = duplicate.assets.length - selectedAssetIds.size;
|
||||
let selectedAssetIds = new Set<string>();
|
||||
$: trashCount = assets.length - selectedAssetIds.size;
|
||||
|
||||
onMount(() => {
|
||||
const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
|
||||
const suggestedAsset = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
|
||||
|
||||
if (!suggestedAsset) {
|
||||
selectedAssetIds = new Set(duplicate.assets[0].id);
|
||||
selectedAssetIds = new Set(assets[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -29,6 +31,10 @@
|
||||
selectedAssetIds = selectedAssetIds;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
});
|
||||
|
||||
const onSelectAsset = (asset: AssetResponseDto) => {
|
||||
if (selectedAssetIds.has(asset.id)) {
|
||||
selectedAssetIds.delete(asset.id);
|
||||
@ -45,89 +51,29 @@
|
||||
};
|
||||
|
||||
const onSelectAll = () => {
|
||||
selectedAssetIds = new Set(duplicate.assets.map((asset) => asset.id));
|
||||
selectedAssetIds = selectedAssetIds;
|
||||
selectedAssetIds = new Set(assets.map((asset) => asset.id));
|
||||
};
|
||||
|
||||
const handleResolve = () => {
|
||||
const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
|
||||
const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
|
||||
const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
|
||||
const duplicateAssetIds = assets.map((asset) => asset.id);
|
||||
onResolve(duplicateAssetIds, trashIds);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[900px] m-auto mb-16">
|
||||
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[54rem] mx-auto mb-16">
|
||||
<div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
|
||||
{#each duplicate.assets as asset, index (index)}
|
||||
{@const isSelected = selectedAssetIds.has(asset.id)}
|
||||
{@const isFromExternalLibrary = !!asset.libraryId}
|
||||
{@const assetData = JSON.stringify(asset, null, 2)}
|
||||
|
||||
<div class="relative">
|
||||
<button type="button" on:click={() => onSelectAsset(asset)} class="block relative">
|
||||
<!-- THUMBNAIL-->
|
||||
<img
|
||||
src={getAssetThumbnailUrl(asset.id)}
|
||||
alt={asset.id}
|
||||
title={`${assetData}`}
|
||||
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- OVERLAY CHIP -->
|
||||
<div
|
||||
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
|
||||
>
|
||||
{isSelected ? $t('keep') : $t('trash')}
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL LIBRARY CHIP-->
|
||||
{#if isFromExternalLibrary}
|
||||
<div
|
||||
class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white"
|
||||
>
|
||||
{$t('external')}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- ASSET INFO-->
|
||||
<table
|
||||
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
|
||||
>
|
||||
<tr
|
||||
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
|
||||
>
|
||||
<td>{asset.originalFileName}</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
|
||||
>
|
||||
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
|
||||
>
|
||||
<td>
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
Scanning for album...
|
||||
{:then albums}
|
||||
{#if albums.length === 0}
|
||||
Not in any album
|
||||
{:else}
|
||||
In {albums.length} album{s(albums.length)}
|
||||
{/if}
|
||||
{/await}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{#each assets as asset (asset.id)}
|
||||
<DuplicateAsset
|
||||
{asset}
|
||||
{onSelectAsset}
|
||||
isSelected={selectedAssetIds.has(asset.id)}
|
||||
onViewAsset={(asset) => setAsset(asset)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex mt-10 mb-4 px-6 w-full place-content-end justify-between h-[45px]">
|
||||
<div class="flex mt-10 mb-4 px-6 w-full place-content-end justify-between h-11">
|
||||
<!-- MARK ALL BUTTONS -->
|
||||
<div class="flex text-xs text-black">
|
||||
<button
|
||||
@ -145,16 +91,36 @@
|
||||
<!-- CONFIRM BUTTONS -->
|
||||
<div class="flex gap-4">
|
||||
{#if trashCount === 0}
|
||||
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
|
||||
><Icon path={mdiCheck} size="20" />Keep All
|
||||
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}>
|
||||
<Icon path={mdiCheck} size="20" />{$t('keep_all')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
|
||||
><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
|
||||
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}>
|
||||
<Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
|
||||
? $t('trash_all')
|
||||
: `${$t('trash')} ${trashCount}`}
|
||||
: $t('trash_count', { values: { count: trashCount } })}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $showAssetViewer}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={assets.length > 1}
|
||||
on:next={() => {
|
||||
const index = getAssetIndex($viewingAsset.id) + 1;
|
||||
setAsset(assets[index % assets.length]);
|
||||
}}
|
||||
on:previous={() => {
|
||||
const index = getAssetIndex($viewingAsset.id) - 1 + assets.length;
|
||||
setAsset(assets[index % assets.length]);
|
||||
}}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
|
@ -8,7 +8,10 @@
|
||||
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
|
||||
<p class="text-xs font-medium p-4">{$t('organize_your_library').toUpperCase()}</p>
|
||||
|
||||
<a href={AppRoute.DUPLICATES} class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
|
||||
<a
|
||||
href={AppRoute.DUPLICATES}
|
||||
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
|
||||
>
|
||||
<span
|
||||
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
|
||||
</span>
|
||||
|
@ -318,6 +318,7 @@
|
||||
"archived": "Archived",
|
||||
"asset_offline": "Asset offline",
|
||||
"assets": "Assets",
|
||||
"assets_moved_to_trash": "Moved {count, plural, one {# asset} other {# assets}} to trash",
|
||||
"authorized_devices": "Authorized Devices",
|
||||
"back": "Back",
|
||||
"backward": "Backward",
|
||||
@ -396,6 +397,7 @@
|
||||
"delete": "Delete",
|
||||
"delete_album": "Delete album",
|
||||
"delete_api_key_prompt": "Are you sure you want to delete this API key?",
|
||||
"delete_duplicates_confirmation": "Are you sure you want to permanently delete these duplicates?",
|
||||
"delete_key": "Delete key",
|
||||
"delete_library": "Delete library",
|
||||
"delete_link": "Delete link",
|
||||
@ -420,6 +422,7 @@
|
||||
"download_settings_description": "Manage settings related to asset download",
|
||||
"downloading": "Downloading",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
|
||||
"duration": "Duration",
|
||||
"durations": {
|
||||
"days": "{days, plural, one {day} other {{days, number} days}}",
|
||||
@ -561,6 +564,7 @@
|
||||
"immich_web_interface": "Immich Web Interface",
|
||||
"import_from_json": "Import from JSON",
|
||||
"import_path": "Import path",
|
||||
"in_albums": "In {count, plural, one {# album} other {# albums}}",
|
||||
"in_archive": "In archive",
|
||||
"include_archived": "Include archived",
|
||||
"include_shared_albums": "Include shared albums",
|
||||
@ -577,6 +581,7 @@
|
||||
"invite_to_album": "Invite to album",
|
||||
"jobs": "Jobs",
|
||||
"keep": "Keep",
|
||||
"keep_all": "Keep All",
|
||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||
"language": "Language",
|
||||
"language_setting_description": "Select your preferred language",
|
||||
@ -756,6 +761,7 @@
|
||||
"scan_all_library_files": "Re-scan All Library Files",
|
||||
"scan_new_library_files": "Scan New Library Files",
|
||||
"scan_settings": "Scan Settings",
|
||||
"scanning_for_album": "Scanning for album...",
|
||||
"search": "Search",
|
||||
"search_albums": "Search albums",
|
||||
"search_by_context": "Search by context",
|
||||
@ -854,12 +860,14 @@
|
||||
"timezone": "Timezone",
|
||||
"to_archive": "Archive",
|
||||
"to_favorite": "Favorite",
|
||||
"to_trash": "Trash",
|
||||
"toggle_settings": "Toggle settings",
|
||||
"toggle_theme": "Toggle theme",
|
||||
"toggle_visibility": "Toggle visibility",
|
||||
"total_usage": "Total usage",
|
||||
"trash": "Trash",
|
||||
"trash_all": "Trash All",
|
||||
"trash_count": "Trash {count}",
|
||||
"trash_no_results_message": "Trashed photos and videos will show up here.",
|
||||
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
|
||||
"type": "Type",
|
||||
@ -898,6 +906,7 @@
|
||||
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
|
||||
"videos": "Videos",
|
||||
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
||||
"view": "View",
|
||||
"view_all": "View All",
|
||||
"view_all_users": "View all users",
|
||||
"view_links": "View links",
|
||||
|
@ -1,18 +1,16 @@
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { s } from '$lib/utils';
|
||||
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteAssets, updateAssets } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@ -20,10 +18,8 @@
|
||||
try {
|
||||
if (!$featureFlags.trash && trashIds.length > 0) {
|
||||
const isConfirmed = await dialogController.show({
|
||||
title: 'Confirm',
|
||||
prompt: 'Are you sure you want to permanently delete these duplicates?',
|
||||
confirmText: 'Yes',
|
||||
cancelText: 'No',
|
||||
prompt: $t('delete_duplicates_confirmation'),
|
||||
confirmText: $t('permanently_delete'),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
@ -41,7 +37,7 @@
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: `Moved ${trashIds.length} asset${s(trashIds.length)} to trash`,
|
||||
message: $t('assets_moved_to_trash', { values: { count: trashIds.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -54,11 +50,11 @@
|
||||
<div class="mt-4">
|
||||
{#if data.duplicates && data.duplicates.length > 0}
|
||||
<div class="mb-4 text-sm dark:text-white">
|
||||
<p>Resolve each group by indicating which, if any, are duplicates.</p>
|
||||
<p>{$t('duplicates_description')}</p>
|
||||
</div>
|
||||
{#key data.duplicates[0].duplicateId}
|
||||
<DuplicatesCompareControl
|
||||
duplicate={data.duplicates[0]}
|
||||
assets={data.duplicates[0].assets}
|
||||
onResolve={(duplicateAssetIds, trashIds) =>
|
||||
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
|
||||
/>
|
Loading…
Reference in New Issue
Block a user