Skip to content

Commit

Permalink
feat(web): full screen view for duplicates (#10346)
Browse files Browse the repository at this point in the history
* feat(web): full screen view for duplicates

* styling: make button visibility better

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
  • Loading branch information
michelheusschen and alextran1502 authored Jun 15, 2024
1 parent 6a54357 commit f3c15c7
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 102 deletions.
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
<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;
}
selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
});
const onSelectAsset = (asset: AssetResponseDto) => {
if (selectedAssetIds.has(asset.id)) {
selectedAssetIds.delete(asset.id);
Expand All @@ -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
Expand All @@ -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}
5 changes: 4 additions & 1 deletion web/src/lib/components/utilities-page/utilities-menu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
9 changes: 9 additions & 0 deletions web/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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}}",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit f3c15c7

Please sign in to comment.