1
0
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:
Michel Heusschen 2024-06-15 22:45:20 +02:00 committed by GitHub
parent 6a5435764e
commit f3c15c7df8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 170 additions and 102 deletions

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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",

View File

@ -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)}
/>