mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
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:
parent
8733d1e554
commit
19ff39c2b9
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user