Compare commits

...

1 Commits

Author SHA1 Message Date
Mees Frensel 881bd83ccf refactor(web): asset trash, delete, restore actions 2026-06-22 18:14:39 +02:00
14 changed files with 148 additions and 219 deletions
@@ -20,7 +20,6 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getSharedLink, handlePromiseError } from '$lib/utils'; import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils'; import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -69,7 +68,6 @@
onAssetChange?: (asset: AssetResponseDto) => void; onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction; preAction?: PreAction;
onAction?: OnAction; onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void; onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void; onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
@@ -85,7 +83,6 @@
onAssetChange, onAssetChange,
preAction, preAction,
onAction, onAction,
onUndoDelete,
onClose, onClose,
onRemoveFromAlbum, onRemoveFromAlbum,
onRandom, onRandom,
@@ -314,11 +311,6 @@
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: { case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack; stack = action.stack;
if (stack) { if (stack) {
@@ -501,7 +493,6 @@
{stack} {stack}
preAction={handlePreAction} preAction={handlePreAction}
onAction={handleAction} onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined} onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum} {onRemoveFromAlbum}
{playOriginalVideo} {playOriginalVideo}
@@ -4,11 +4,9 @@
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte'; import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte'; import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
@@ -25,9 +23,8 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service'; import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service'; import { getAssetActions, handleTrashOrDelete } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils'; import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetTypeEnum, AssetTypeEnum,
@@ -37,7 +34,7 @@
type PersonResponseDto, type PersonResponseDto,
type StackResponseDto, type StackResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui'; import { ActionButton, CommandPaletteDefaultProvider, shortcut, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js'; import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -48,7 +45,6 @@
stack?: StackResponseDto | null; stack?: StackResponseDto | null;
preAction: PreAction; preAction: PreAction;
onAction: OnAction; onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void; onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void; onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean; playOriginalVideo: boolean;
@@ -62,7 +58,6 @@
stack = null, stack = null,
preAction, preAction,
onAction, onAction,
onUndoDelete = undefined,
onClose, onClose,
onRemoveFromAlbum, onRemoveFromAlbum,
playOriginalVideo = false, playOriginalVideo = false,
@@ -88,6 +83,10 @@
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
</script> </script>
<svelte:document
use:shortcut={{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => handleTrashOrDelete(asset, true) }}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} /> <CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div <div
@@ -128,10 +127,8 @@
{/if} {/if}
<ActionButton action={Actions.Edit} /> <ActionButton action={Actions.Edit} />
<ActionButton action={Actions.Delete} />
{#if isOwner} <ActionButton action={Actions.PermanentlyDelete} />
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
{#if !sharedLink} {#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}> <ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
@@ -139,10 +136,7 @@
<ActionMenuItem action={Actions.Download} /> <ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} /> <ActionMenuItem action={Actions.DownloadOriginal} />
<ActionMenuItem action={Actions.Restore} />
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} /> <ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)} {#if album && (isOwner || isAlbumOwner)}
@@ -1,48 +0,0 @@
import type { AssetResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
describe('but if the asset is already trashed', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: true });
});
it('displays a button to permanently delete the asset', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
});
@@ -1,75 +0,0 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
@@ -1,31 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
@@ -5,9 +5,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
type ActionMap = { type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset }; [AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
@@ -22,6 +22,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte'; import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
interface Props { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;
@@ -63,15 +64,20 @@
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.ARCHIVE: case AssetAction.ARCHIVE: {
case AssetAction.DELETE:
case AssetAction.TRASH: {
await goto(Route.photos()); await goto(Route.photos());
break; break;
} }
// no default // no default
} }
}; };
const onAssetsDelete = async (assetIds: string[]) => {
// Only used for single asset shared link
if (assetIds.includes(assets[0].id)) {
await goto(Route.photos());
}
};
</script> </script>
{#if sharedLink?.allowUpload || assets.length > 1} {#if sharedLink?.allowUpload || assets.length > 1}
@@ -132,6 +138,8 @@
{/if} {/if}
</header> </header>
{:else if assets.length === 1} {:else if assets.length === 1}
<OnEvents {onAssetsDelete} />
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} /> <AssetViewer cursor={{ current: asset }} onAction={handleAction} />
@@ -4,6 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte'; import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
@@ -285,9 +286,7 @@
const handleAction = async (action: Action) => { const handleAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case AssetAction.ARCHIVE: case AssetAction.ARCHIVE: {
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset; const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice( assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id), assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
@@ -305,6 +304,17 @@
} }
}; };
const onAssetsDelete = async (assetIds: string[]) => {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets = assets.filter((asset) => !assetIds.includes(asset.id));
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetIds.includes(assetCursor.current.id) && nextAsset) {
await navigateToAsset(nextAsset);
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => { const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset); handleSelectAssetCandidates(asset);
@@ -338,6 +348,8 @@
<svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} /> <svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} />
<OnEvents {onAssetsDelete} />
{#if assets.length > 0} {#if assets.length > 0}
<div <div
style:position="relative" style:position="relative"
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte'; import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -78,6 +78,14 @@
}; };
}; };
/** Find the next asset to show or close the viewer */
const navigateOrCloseViewer = async (id: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(id));
};
//TODO: replace this with async derived in svelte 6 //TODO: replace this with async derived in svelte 6
$effect(() => { $effect(() => {
const asset = assetViewerManager.asset; const asset = assetViewerManager.asset;
@@ -109,35 +117,20 @@
const handleRemoveFromAlbum = async (assetIds: string[]) => { const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds); timelineManager.removeAssets(assetIds);
if (!assetIds.includes(assetCursor.current.id)) { if (assetIds.includes(assetCursor.current.id)) {
return; await navigateOrCloseViewer(assetCursor.current.id);
} }
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current.id));
}; };
const handlePreAction = async (action: Action) => { const handlePreAction = async (action: Action) => {
switch (action.type) { switch (action.type) {
case removeAction: case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE: case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED: case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: { case AssetAction.SET_VISIBILITY_TIMELINE: {
// must update manager before performing any navigation // must update manager before performing any navigation
timelineManager.removeAssets([action.asset.id]); timelineManager.removeAssets([action.asset.id]);
await navigateOrCloseViewer(action.asset.id);
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset.id));
break; break;
} }
// no default // no default
@@ -199,9 +192,19 @@
// no default // no default
} }
}; };
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets); const onAssetsDelete = async (assetIds: string[]) => {
if (assets.length === 0) { timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
}
};
const onAssetsRestore = async (assets: AssetResponseDto[]) => {
timelineManager.upsertAssets(assets.map((a) => toTimelineAsset(a)));
if (assets.length !== 1) {
// don't reopen asset viewer if multiple assets were restored (bulk action)
return; return;
} }
@@ -234,6 +237,8 @@
}); });
</script> </script>
<OnEvents {onAssetsDelete} {onAssetsRestore} />
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer <AssetViewer
{withStacked} {withStacked}
@@ -249,7 +254,6 @@
handleAction(action); handleAction(action);
assetCacheManager.invalidate(); assetCacheManager.invalidate();
}} }}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom} onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum} onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose} onClose={handleClose}
-3
View File
@@ -3,9 +3,6 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction { export enum AssetAction {
ARCHIVE = 'archive', ARCHIVE = 'archive',
UNARCHIVE = 'unarchive', UNARCHIVE = 'unarchive',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack', STACK = 'stack',
UNSTACK = 'unstack', UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
@@ -36,6 +36,7 @@ export type Events = {
AssetUpdate: [AssetResponseDto]; AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]]; AssetsArchive: [string[]];
AssetsDelete: [string[]]; AssetsDelete: [string[]];
AssetsRestore: [AssetResponseDto[]];
AssetEditsApplied: [string]; AssetEditsApplied: [string];
AssetsTag: [string[]]; AssetsTag: [string[]];
@@ -31,6 +31,12 @@ vitest.mock('$lib/utils', async () => {
}; };
}); });
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => { describe('AssetService', () => {
describe('getAssetActions', () => { describe('getAssetActions', () => {
beforeEach(() => { beforeEach(() => {
+77
View File
@@ -3,7 +3,9 @@ import {
AssetMediaSize, AssetMediaSize,
AssetTypeEnum, AssetTypeEnum,
AssetVisibility, AssetVisibility,
deleteAssets,
getAssetInfo, getAssetInfo,
restoreAssets,
runAssetJobs, runAssetJobs,
updateAsset, updateAsset,
type AssetJobsDto, type AssetJobsDto,
@@ -15,12 +17,15 @@ import {
mdiCogRefreshOutline, mdiCogRefreshOutline,
mdiContentCopy, mdiContentCopy,
mdiDatabaseRefreshOutline, mdiDatabaseRefreshOutline,
mdiDeleteForeverOutline,
mdiDeleteOutline,
mdiDownload, mdiDownload,
mdiDownloadBox, mdiDownloadBox,
mdiFaceRecognition, mdiFaceRecognition,
mdiHeadSyncOutline, mdiHeadSyncOutline,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
mdiHistory,
mdiImageRefreshOutline, mdiImageRefreshOutline,
mdiInformationOutline, mdiInformationOutline,
mdiMagnifyMinusOutline, mdiMagnifyMinusOutline,
@@ -34,14 +39,18 @@ import {
mdiTune, mdiTune,
} from '@mdi/js'; } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils'; import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils'; import { downloadUrl } from '$lib/utils';
@@ -96,6 +105,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined; const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId); const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash;
const Share: ActionItem = { const Share: ActionItem = {
title: $t('share'), title: $t('share'),
@@ -242,6 +252,29 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }], shortcuts: [{ key: 'e' }],
}; };
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDeleteOutline,
$if: () => isOwner && !isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset),
shortcuts: { key: 'Delete' },
};
const PermanentlyDelete: ActionItem = {
title: $t('permanently_delete'),
icon: mdiDeleteForeverOutline,
$if: () => isOwner && isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset, true),
shortcuts: { key: 'Delete', shift: true },
};
const Restore: ActionItem = {
title: $t('restore'),
icon: mdiHistory,
$if: () => asset.visibility !== AssetVisibility.Locked && asset.isTrashed,
onAction: () => handleRestore(asset),
};
const RefreshFacesJob: ActionItem = { const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'), title: $t('refresh_faces'),
icon: mdiHeadSyncOutline, icon: mdiHeadSyncOutline,
@@ -286,6 +319,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag, Tag,
TagPeople, TagPeople,
Edit, Edit,
Delete,
PermanentlyDelete,
Restore,
RefreshFacesJob, RefreshFacesJob,
RefreshMetadataJob, RefreshMetadataJob,
RegenerateThumbnailJob, RegenerateThumbnailJob,
@@ -367,6 +403,47 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
} }
}; };
export const handleTrashOrDelete = async (asset: AssetResponseDto, force?: boolean) => {
const $t = await getFormatter();
if (force && get(showDeleteModal)) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force } });
eventManager.emit('AssetsDelete', [asset.id]);
if (force) {
toastManager.primary($t('permanently_deleted_asset'));
} else {
toastManager.primary(
{
description: $t('moved_to_trash'),
button: { label: $t('undo'), color: 'secondary', onclick: () => handleRestore(asset) },
},
{ timeout: 5000 },
);
}
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
};
const handleRestore = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
eventManager.emit('AssetsRestore', [asset]);
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = { const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'), [AssetJobName.RefreshFaces]: $t('refreshing_faces'),
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte'; import LargeAssetData from './LargeAssetData.svelte';
@@ -37,16 +36,14 @@
return asset; return asset;
}; };
const preAction = async (payload: Action) => { const onAssetsDelete = async (assetIds: string[]) => {
if (payload.type == 'trash') { if (assetIds.includes(assetCursor.current.id)) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) || (await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) || (await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false); assetViewerManager.showAssetViewer(false);
} }
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id)); assets = assets.filter(({ id }) => !assetIds.includes(id));
}; };
@@ -84,7 +81,6 @@
cursor={assetCursor} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onRandom} {onRandom}
{preAction}
onClose={() => { onClose={() => {
assetViewerManager.showAssetViewer(false); assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));