feat(web): undo delete (#18729)

* feat(web): Undo asset delete

* - lints and checks
- Update English translation

* Update delete-assets.svelte

Make onUndoDelete optional in Props interface

* - Ensure undo button not available on permanent delete, or trash disabled.
- Enforce lint requirement for no-negated-condition

* Fix formatting

* fix: lint

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
xCJPECKOVERx 2025-06-04 11:46:07 -04:00 committed by GitHub
parent 8733d1e554
commit 19ff39c2b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 74 additions and 17 deletions

View File

@ -1841,6 +1841,7 @@
"unable_to_setup_pin_code": "Unable to setup PIN code", "unable_to_setup_pin_code": "Unable to setup PIN code",
"unarchive": "Unarchive", "unarchive": "Unarchive",
"unarchived_count": "{count, plural, other {Unarchived #}}", "unarchived_count": "{count, plural, other {Unarchived #}}",
"undo": "Undo",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
"unhide_person": "Unhide person", "unhide_person": "Unhide person",
"unknown": "Unknown", "unknown": "Unknown",

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { type OnDelete, deleteAssets } from '$lib/utils/actions'; import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
@ -10,11 +10,12 @@
interface Props { interface Props {
onAssetDelete: OnDelete; onAssetDelete: OnDelete;
onUndoDelete?: OnUndoDelete | undefined;
menuItem?: boolean; menuItem?: boolean;
force?: boolean; force?: boolean;
} }
let { onAssetDelete, menuItem = false, force = !$featureFlags.trash }: Props = $props(); let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
@ -34,8 +35,8 @@
const handleDelete = async () => { const handleDelete = async () => {
loading = true; loading = true;
const ids = [...getOwnedAssets()].map((a) => a.id); const assets = [...getOwnedAssets()];
await deleteAssets(force, onAssetDelete, ids); await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
clearSelect(); clearSelect();
isShowConfirmation = false; isShowConfirmation = false;
loading = false; loading = false;

View File

@ -382,7 +382,12 @@
const trashOrDelete = async (force: boolean = false) => { const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false; isShowDeleteConfirmation = false;
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets); await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => assetStore.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => assetStore.addAssets(assets),
);
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };

View File

@ -38,6 +38,7 @@
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined; onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<{ id: string } | undefined>) | undefined; onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined; onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
onReload?: (() => void) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
slidingWindowOffset?: number; slidingWindowOffset?: number;
} }
@ -54,6 +55,7 @@
onPrevious = undefined, onPrevious = undefined,
onNext = undefined, onNext = undefined,
onRandom = undefined, onRandom = undefined,
onReload = undefined,
slidingWindowOffset = 0, slidingWindowOffset = 0,
pageHeaderOffset = 0, pageHeaderOffset = 0,
}: Props = $props(); }: Props = $props();
@ -255,7 +257,8 @@
await deleteAssets( await deleteAssets(
!(isTrashEnabled && !force), !(isTrashEnabled && !force),
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))), (assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
idsSelectedAssets, assetInteraction.selectedAssets,
onReload,
); );
assetInteraction.clearMultiselect(); assetInteraction.clearMultiselect();
}; };
@ -426,7 +429,6 @@
}; };
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map((selectedAsset) => selectedAsset.id));
$effect(() => { $effect(() => {
if (!lastAssetMouseEvent) { if (!lastAssetMouseEvent) {

View File

@ -1,12 +1,13 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { StackResponse } from '$lib/utils/asset-utils'; import type { StackResponse } from '$lib/utils/asset-utils';
import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk'; import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { handleError } from './handle-error'; import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnUndoDelete = (assets: TimelineAsset[]) => void;
export type OnRestore = (ids: string[]) => void; export type OnRestore = (ids: string[]) => void;
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
@ -17,9 +18,15 @@ export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: TimelineAsset[]) => void; export type OnUnstack = (assets: TimelineAsset[]) => void;
export type OnSetVisibility = (ids: string[]) => void; export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { export const deleteAssets = async (
force: boolean,
onAssetDelete: OnDelete,
assets: TimelineAsset[],
onUndoDelete: OnUndoDelete | undefined = undefined,
) => {
const $t = get(t); const $t = get(t);
try { try {
const ids = assets.map((a) => a.id);
await deleteBulk({ assetBulkDeleteDto: { ids, force } }); await deleteBulk({ assetBulkDeleteDto: { ids, force } });
onAssetDelete(ids); onAssetDelete(ids);
@ -28,12 +35,28 @@ export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids:
? $t('assets_permanently_deleted_count', { values: { count: ids.length } }) ? $t('assets_permanently_deleted_count', { values: { count: ids.length } })
: $t('assets_trashed_count', { values: { count: ids.length } }), : $t('assets_trashed_count', { values: { count: ids.length } }),
type: NotificationType.Info, type: NotificationType.Info,
...(onUndoDelete &&
!force && {
button: { text: $t('undo'), onClick: () => undoDeleteAssets(onUndoDelete, assets) },
timeout: 5000,
}),
}); });
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_delete_assets')); handleError(error, $t('errors.unable_to_delete_assets'));
} }
}; };
const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsset[]) => {
const $t = get(t);
try {
const ids = assets.map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onUndoDelete?.(assets);
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
/** /**
* Update the asset stack state in the asset store based on the provided stack response. * Update the asset stack state in the asset store based on the provided stack response.
* This function updates the stack information so that the icon is shown for the primary asset * This function updates the stack information so that the icon is shown for the primary asset

View File

@ -87,6 +87,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { type TimelineAsset } from '$lib/stores/assets-store.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -317,6 +318,11 @@
await refreshAlbum(); await refreshAlbum();
}; };
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
assetStore.addAssets(assets);
await refreshAlbum();
};
const handleUpdateThumbnail = async (assetId: string) => { const handleUpdateThumbnail = async (assetId: string) => {
if (viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL) { if (viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL) {
return; return;
@ -623,7 +629,7 @@
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} /> <RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
{/if} {/if}
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} /> <DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} />
{/if} {/if}
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>

View File

@ -13,6 +13,7 @@
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
@ -20,7 +21,6 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte';
interface Props { interface Props {
data: PageData; data: PageData;

View File

@ -92,7 +92,11 @@
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets
menuItem
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
onUndoDelete={(assets) => assetStore.addAssets(assets)}
/>
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}

View File

@ -122,6 +122,7 @@
{viewport} {viewport}
showAssetName={true} showAssetName={true}
pageHeaderOffset={54} pageHeaderOffset={54}
onReload={triggerAssetUpdate}
/> />
</div> </div>
{/if} {/if}
@ -170,7 +171,7 @@
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<DeleteAssets menuItem onAssetDelete={triggerAssetUpdate} /> <DeleteAssets menuItem onAssetDelete={triggerAssetUpdate} onUndoDelete={triggerAssetUpdate} />
<hr /> <hr />
<AssetJobActions /> <AssetJobActions />
</ButtonContextMenu> </ButtonContextMenu>

View File

@ -351,6 +351,11 @@
await updateAssetCount(); await updateAssetCount();
}; };
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
assetStore.addAssets(assets);
await updateAssetCount();
};
let person = $derived(data.person); let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person)); let thumbnailData = $derived(getPeopleThumbnailUrl(person));
@ -532,7 +537,11 @@
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)} /> <DeleteAssets
menuItem
onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)}
onUndoDelete={(assets) => handleUndoDeleteAssets(assets)}
/>
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}

View File

@ -150,7 +150,11 @@
{#if $preferences.tags.enabled} {#if $preferences.tags.enabled}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} /> <DeleteAssets
menuItem
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
onUndoDelete={(assets) => assetStore.addAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr /> <hr />
<AssetJobActions /> <AssetJobActions />

View File

@ -301,7 +301,7 @@
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<DeleteAssets menuItem {onAssetDelete} /> <DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<hr /> <hr />
<AssetJobActions /> <AssetJobActions />
</ButtonContextMenu> </ButtonContextMenu>
@ -382,6 +382,7 @@
showArchiveIcon={true} showArchiveIcon={true}
{viewport} {viewport}
pageHeaderOffset={54} pageHeaderOffset={54}
onReload={onSearchQueryUpdate}
/> />
{:else if !isLoading} {:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white"> <div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
@ -444,7 +445,7 @@
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem /> <TagAction menuItem />
{/if} {/if}
<DeleteAssets menuItem {onAssetDelete} /> <DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<hr /> <hr />
<AssetJobActions /> <AssetJobActions />
</ButtonContextMenu> </ButtonContextMenu>