mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05: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">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Button from '$lib/components/elements/buttons/button.svelte';
 | 
					  import Button from '$lib/components/elements/buttons/button.svelte';
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
  import { getAssetThumbnailUrl } from '$lib/utils';
 | 
					  import Portal from '$lib/components/shared-components/portal/portal.svelte';
 | 
				
			||||||
  import { type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
 | 
					  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 { 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 { sortBy } from 'lodash-es';
 | 
				
			||||||
 | 
					  import { onDestroy, onMount } from 'svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let duplicate: DuplicateResponseDto;
 | 
					  export let assets: AssetResponseDto[];
 | 
				
			||||||
  export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
 | 
					  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(() => {
 | 
					  onMount(() => {
 | 
				
			||||||
    const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
 | 
					    const suggestedAsset = sortBy(assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!suggestedAsset) {
 | 
					    if (!suggestedAsset) {
 | 
				
			||||||
      selectedAssetIds = new Set(duplicate.assets[0].id);
 | 
					      selectedAssetIds = new Set(assets[0].id);
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -29,6 +31,10 @@
 | 
				
			|||||||
    selectedAssetIds = selectedAssetIds;
 | 
					    selectedAssetIds = selectedAssetIds;
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onDestroy(() => {
 | 
				
			||||||
 | 
					    assetViewingStore.showAssetViewer(false);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSelectAsset = (asset: AssetResponseDto) => {
 | 
					  const onSelectAsset = (asset: AssetResponseDto) => {
 | 
				
			||||||
    if (selectedAssetIds.has(asset.id)) {
 | 
					    if (selectedAssetIds.has(asset.id)) {
 | 
				
			||||||
      selectedAssetIds.delete(asset.id);
 | 
					      selectedAssetIds.delete(asset.id);
 | 
				
			||||||
@ -45,89 +51,29 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onSelectAll = () => {
 | 
					  const onSelectAll = () => {
 | 
				
			||||||
    selectedAssetIds = new Set(duplicate.assets.map((asset) => asset.id));
 | 
					    selectedAssetIds = new Set(assets.map((asset) => asset.id));
 | 
				
			||||||
    selectedAssetIds = selectedAssetIds;
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleResolve = () => {
 | 
					  const handleResolve = () => {
 | 
				
			||||||
    const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
 | 
					    const trashIds = assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
 | 
				
			||||||
    const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
 | 
					    const duplicateAssetIds = assets.map((asset) => asset.id);
 | 
				
			||||||
    onResolve(duplicateAssetIds, trashIds);
 | 
					    onResolve(duplicateAssetIds, trashIds);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</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">
 | 
					  <div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
 | 
				
			||||||
    {#each duplicate.assets as asset, index (index)}
 | 
					    {#each assets as asset (asset.id)}
 | 
				
			||||||
      {@const isSelected = selectedAssetIds.has(asset.id)}
 | 
					      <DuplicateAsset
 | 
				
			||||||
      {@const isFromExternalLibrary = !!asset.libraryId}
 | 
					        {asset}
 | 
				
			||||||
      {@const assetData = JSON.stringify(asset, null, 2)}
 | 
					        {onSelectAsset}
 | 
				
			||||||
 | 
					        isSelected={selectedAssetIds.has(asset.id)}
 | 
				
			||||||
      <div class="relative">
 | 
					        onViewAsset={(asset) => setAsset(asset)}
 | 
				
			||||||
        <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}
 | 
					    {/each}
 | 
				
			||||||
  </div>
 | 
					  </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 -->
 | 
					    <!-- MARK ALL BUTTONS -->
 | 
				
			||||||
    <div class="flex text-xs text-black">
 | 
					    <div class="flex text-xs text-black">
 | 
				
			||||||
      <button
 | 
					      <button
 | 
				
			||||||
@ -145,16 +91,36 @@
 | 
				
			|||||||
    <!-- CONFIRM BUTTONS -->
 | 
					    <!-- CONFIRM BUTTONS -->
 | 
				
			||||||
    <div class="flex gap-4">
 | 
					    <div class="flex gap-4">
 | 
				
			||||||
      {#if trashCount === 0}
 | 
					      {#if trashCount === 0}
 | 
				
			||||||
        <Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
 | 
					        <Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}>
 | 
				
			||||||
          ><Icon path={mdiCheck} size="20" />Keep All
 | 
					          <Icon path={mdiCheck} size="20" />{$t('keep_all')}
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
      {:else}
 | 
					      {:else}
 | 
				
			||||||
        <Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
 | 
					        <Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}>
 | 
				
			||||||
          ><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
 | 
					          <Icon path={mdiTrashCanOutline} size="20" />{trashCount === assets.length
 | 
				
			||||||
            ? $t('trash_all')
 | 
					            ? $t('trash_all')
 | 
				
			||||||
            : `${$t('trash')} ${trashCount}`}
 | 
					            : $t('trash_count', { values: { count: trashCount } })}
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
      {/if}
 | 
					      {/if}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </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">
 | 
					<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>
 | 
					  <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
 | 
					    <span
 | 
				
			||||||
      ><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
 | 
					      ><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
 | 
				
			|||||||
@ -318,6 +318,7 @@
 | 
				
			|||||||
  "archived": "Archived",
 | 
					  "archived": "Archived",
 | 
				
			||||||
  "asset_offline": "Asset offline",
 | 
					  "asset_offline": "Asset offline",
 | 
				
			||||||
  "assets": "Assets",
 | 
					  "assets": "Assets",
 | 
				
			||||||
 | 
					  "assets_moved_to_trash": "Moved {count, plural, one {# asset} other {# assets}} to trash",
 | 
				
			||||||
  "authorized_devices": "Authorized Devices",
 | 
					  "authorized_devices": "Authorized Devices",
 | 
				
			||||||
  "back": "Back",
 | 
					  "back": "Back",
 | 
				
			||||||
  "backward": "Backward",
 | 
					  "backward": "Backward",
 | 
				
			||||||
@ -396,6 +397,7 @@
 | 
				
			|||||||
  "delete": "Delete",
 | 
					  "delete": "Delete",
 | 
				
			||||||
  "delete_album": "Delete album",
 | 
					  "delete_album": "Delete album",
 | 
				
			||||||
  "delete_api_key_prompt": "Are you sure you want to delete this API key?",
 | 
					  "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_key": "Delete key",
 | 
				
			||||||
  "delete_library": "Delete library",
 | 
					  "delete_library": "Delete library",
 | 
				
			||||||
  "delete_link": "Delete link",
 | 
					  "delete_link": "Delete link",
 | 
				
			||||||
@ -420,6 +422,7 @@
 | 
				
			|||||||
  "download_settings_description": "Manage settings related to asset download",
 | 
					  "download_settings_description": "Manage settings related to asset download",
 | 
				
			||||||
  "downloading": "Downloading",
 | 
					  "downloading": "Downloading",
 | 
				
			||||||
  "duplicates": "Duplicates",
 | 
					  "duplicates": "Duplicates",
 | 
				
			||||||
 | 
					  "duplicates_description": "Resolve each group by indicating which, if any, are duplicates",
 | 
				
			||||||
  "duration": "Duration",
 | 
					  "duration": "Duration",
 | 
				
			||||||
  "durations": {
 | 
					  "durations": {
 | 
				
			||||||
    "days": "{days, plural, one {day} other {{days, number} days}}",
 | 
					    "days": "{days, plural, one {day} other {{days, number} days}}",
 | 
				
			||||||
@ -561,6 +564,7 @@
 | 
				
			|||||||
  "immich_web_interface": "Immich Web Interface",
 | 
					  "immich_web_interface": "Immich Web Interface",
 | 
				
			||||||
  "import_from_json": "Import from JSON",
 | 
					  "import_from_json": "Import from JSON",
 | 
				
			||||||
  "import_path": "Import path",
 | 
					  "import_path": "Import path",
 | 
				
			||||||
 | 
					  "in_albums": "In {count, plural, one {# album} other {# albums}}",
 | 
				
			||||||
  "in_archive": "In archive",
 | 
					  "in_archive": "In archive",
 | 
				
			||||||
  "include_archived": "Include archived",
 | 
					  "include_archived": "Include archived",
 | 
				
			||||||
  "include_shared_albums": "Include shared albums",
 | 
					  "include_shared_albums": "Include shared albums",
 | 
				
			||||||
@ -577,6 +581,7 @@
 | 
				
			|||||||
  "invite_to_album": "Invite to album",
 | 
					  "invite_to_album": "Invite to album",
 | 
				
			||||||
  "jobs": "Jobs",
 | 
					  "jobs": "Jobs",
 | 
				
			||||||
  "keep": "Keep",
 | 
					  "keep": "Keep",
 | 
				
			||||||
 | 
					  "keep_all": "Keep All",
 | 
				
			||||||
  "keyboard_shortcuts": "Keyboard shortcuts",
 | 
					  "keyboard_shortcuts": "Keyboard shortcuts",
 | 
				
			||||||
  "language": "Language",
 | 
					  "language": "Language",
 | 
				
			||||||
  "language_setting_description": "Select your preferred language",
 | 
					  "language_setting_description": "Select your preferred language",
 | 
				
			||||||
@ -756,6 +761,7 @@
 | 
				
			|||||||
  "scan_all_library_files": "Re-scan All Library Files",
 | 
					  "scan_all_library_files": "Re-scan All Library Files",
 | 
				
			||||||
  "scan_new_library_files": "Scan New Library Files",
 | 
					  "scan_new_library_files": "Scan New Library Files",
 | 
				
			||||||
  "scan_settings": "Scan Settings",
 | 
					  "scan_settings": "Scan Settings",
 | 
				
			||||||
 | 
					  "scanning_for_album": "Scanning for album...",
 | 
				
			||||||
  "search": "Search",
 | 
					  "search": "Search",
 | 
				
			||||||
  "search_albums": "Search albums",
 | 
					  "search_albums": "Search albums",
 | 
				
			||||||
  "search_by_context": "Search by context",
 | 
					  "search_by_context": "Search by context",
 | 
				
			||||||
@ -854,12 +860,14 @@
 | 
				
			|||||||
  "timezone": "Timezone",
 | 
					  "timezone": "Timezone",
 | 
				
			||||||
  "to_archive": "Archive",
 | 
					  "to_archive": "Archive",
 | 
				
			||||||
  "to_favorite": "Favorite",
 | 
					  "to_favorite": "Favorite",
 | 
				
			||||||
 | 
					  "to_trash": "Trash",
 | 
				
			||||||
  "toggle_settings": "Toggle settings",
 | 
					  "toggle_settings": "Toggle settings",
 | 
				
			||||||
  "toggle_theme": "Toggle theme",
 | 
					  "toggle_theme": "Toggle theme",
 | 
				
			||||||
  "toggle_visibility": "Toggle visibility",
 | 
					  "toggle_visibility": "Toggle visibility",
 | 
				
			||||||
  "total_usage": "Total usage",
 | 
					  "total_usage": "Total usage",
 | 
				
			||||||
  "trash": "Trash",
 | 
					  "trash": "Trash",
 | 
				
			||||||
  "trash_all": "Trash All",
 | 
					  "trash_all": "Trash All",
 | 
				
			||||||
 | 
					  "trash_count": "Trash {count}",
 | 
				
			||||||
  "trash_no_results_message": "Trashed photos and videos will show up here.",
 | 
					  "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}}.",
 | 
					  "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
 | 
				
			||||||
  "type": "Type",
 | 
					  "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.",
 | 
					  "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": "Videos",
 | 
				
			||||||
  "videos_count": "{count, plural, one {# Video} other {# Videos}}",
 | 
					  "videos_count": "{count, plural, one {# Video} other {# Videos}}",
 | 
				
			||||||
 | 
					  "view": "View",
 | 
				
			||||||
  "view_all": "View All",
 | 
					  "view_all": "View All",
 | 
				
			||||||
  "view_all_users": "View all users",
 | 
					  "view_all_users": "View all users",
 | 
				
			||||||
  "view_links": "View links",
 | 
					  "view_links": "View links",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,18 +1,16 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
					  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
				
			||||||
  import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
 | 
					  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
 | 
				
			||||||
 | 
					 | 
				
			||||||
  import type { PageData } from './$types';
 | 
					 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    NotificationType,
 | 
					    NotificationType,
 | 
				
			||||||
    notificationController,
 | 
					    notificationController,
 | 
				
			||||||
  } from '$lib/components/shared-components/notification/notification';
 | 
					  } 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 { deleteAssets, updateAssets } from '@immich/sdk';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import { featureFlags } from '$lib/stores/server-config.store';
 | 
					  import type { PageData } from './$types';
 | 
				
			||||||
  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let data: PageData;
 | 
					  export let data: PageData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -20,10 +18,8 @@
 | 
				
			|||||||
    try {
 | 
					    try {
 | 
				
			||||||
      if (!$featureFlags.trash && trashIds.length > 0) {
 | 
					      if (!$featureFlags.trash && trashIds.length > 0) {
 | 
				
			||||||
        const isConfirmed = await dialogController.show({
 | 
					        const isConfirmed = await dialogController.show({
 | 
				
			||||||
          title: 'Confirm',
 | 
					          prompt: $t('delete_duplicates_confirmation'),
 | 
				
			||||||
          prompt: 'Are you sure you want to permanently delete these duplicates?',
 | 
					          confirmText: $t('permanently_delete'),
 | 
				
			||||||
          confirmText: 'Yes',
 | 
					 | 
				
			||||||
          cancelText: 'No',
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!isConfirmed) {
 | 
					        if (!isConfirmed) {
 | 
				
			||||||
@ -41,7 +37,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      notificationController.show({
 | 
					      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,
 | 
					        type: NotificationType.Info,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
@ -54,11 +50,11 @@
 | 
				
			|||||||
  <div class="mt-4">
 | 
					  <div class="mt-4">
 | 
				
			||||||
    {#if data.duplicates && data.duplicates.length > 0}
 | 
					    {#if data.duplicates && data.duplicates.length > 0}
 | 
				
			||||||
      <div class="mb-4 text-sm dark:text-white">
 | 
					      <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>
 | 
					      </div>
 | 
				
			||||||
      {#key data.duplicates[0].duplicateId}
 | 
					      {#key data.duplicates[0].duplicateId}
 | 
				
			||||||
        <DuplicatesCompareControl
 | 
					        <DuplicatesCompareControl
 | 
				
			||||||
          duplicate={data.duplicates[0]}
 | 
					          assets={data.duplicates[0].assets}
 | 
				
			||||||
          onResolve={(duplicateAssetIds, trashIds) =>
 | 
					          onResolve={(duplicateAssetIds, trashIds) =>
 | 
				
			||||||
            handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
 | 
					            handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user