Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): full screen view for duplicates #10346

Merged
merged 2 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-500 p-1.5 hover:text-white"
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 @@ -317,6 +317,7 @@
"archived": "Archived",
"asset_offline": "Asset offline",
"assets": "Assets",
"assets_moved_to_trash": "Moved {count, plural, one {# asset} other {# assets}} to trash",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does # work similar to {count, number}?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes exactly, we're already using the shorter # syntax for some of our existing translations

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, that's cool (and easier)! :D

"authorized_devices": "Authorized Devices",
"back": "Back",
"backward": "Backward",
Expand Down Expand Up @@ -395,6 +396,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 @@ -419,6 +421,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 @@ -560,6 +563,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 @@ -576,6 +580,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 @@ -755,6 +760,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 @@ -853,12 +859,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 @@ -897,6 +905,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
Loading