diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index 9606077d52..3051c62acf 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -20,7 +20,6 @@ import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { getSharedLink, handlePromiseError } from '$lib/utils'; - import type { OnUndoDelete } from '$lib/utils/actions'; import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { InvocationTracker } from '$lib/utils/invocationTracker'; @@ -69,7 +68,6 @@ onAssetChange?: (asset: AssetResponseDto) => void; preAction?: PreAction; onAction?: OnAction; - onUndoDelete?: OnUndoDelete; onClose?: (assetId: string) => void; onRemoveFromAlbum?: (assetIds: string[]) => void; onRandom?: () => Promise<{ id: string } | undefined>; @@ -85,7 +83,6 @@ onAssetChange, preAction, onAction, - onUndoDelete, onClose, onRemoveFromAlbum, onRandom, @@ -314,11 +311,6 @@ const handleAction = async (action: Action) => { switch (action.type) { - case AssetAction.DELETE: - case AssetAction.TRASH: { - eventManager.emit('AssetsDelete', [asset.id]); - break; - } case AssetAction.REMOVE_ASSET_FROM_STACK: { stack = action.stack; if (stack) { @@ -501,7 +493,6 @@ {stack} preAction={handlePreAction} onAction={handleAction} - {onUndoDelete} onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined} {onRemoveFromAlbum} {playOriginalVideo} diff --git a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte index d6d90aca8a..231a3dfc70 100644 --- a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte @@ -4,11 +4,9 @@ import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.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 RatingAction from '$lib/components/asset-viewer/actions/RatingAction.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 SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte'; import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte'; @@ -25,9 +23,8 @@ import { Route } from '$lib/route'; import { getAlbumAssetActions } from '$lib/services/album.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 type { OnUndoDelete } from '$lib/utils/actions'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetTypeEnum, @@ -37,7 +34,7 @@ type PersonResponseDto, type StackResponseDto, } 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 { t } from 'svelte-i18n'; @@ -48,7 +45,6 @@ stack?: StackResponseDto | null; preAction: PreAction; onAction: OnAction; - onUndoDelete?: OnUndoDelete; onClose?: () => void; onRemoveFromAlbum?: (assetIds: string[]) => void; playOriginalVideo: boolean; @@ -62,7 +58,6 @@ stack = null, preAction, onAction, - onUndoDelete = undefined, onClose, onRemoveFromAlbum, playOriginalVideo = false, @@ -88,6 +83,10 @@ const sharedLink = getSharedLink(); + handleTrashOrDelete(asset, true) }} +/> +
- - {#if isOwner} - - {/if} + + {#if !sharedLink} @@ -139,10 +136,7 @@ - - {#if !isLocked && asset.isTrashed} - - {/if} + {#if album && (isOwner || isAlbumOwner)} diff --git a/web/src/lib/components/asset-viewer/actions/DeleteAction.spec.ts b/web/src/lib/components/asset-viewer/actions/DeleteAction.spec.ts deleted file mode 100644 index 6cd95ab02a..0000000000 --- a/web/src/lib/components/asset-viewer/actions/DeleteAction.spec.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/web/src/lib/components/asset-viewer/actions/DeleteAction.svelte b/web/src/lib/components/asset-viewer/actions/DeleteAction.svelte deleted file mode 100644 index 60cf59a7ca..0000000000 --- a/web/src/lib/components/asset-viewer/actions/DeleteAction.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - trashOrDelete() }, - { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, - ]} -/> - - trashOrDelete()} -/> diff --git a/web/src/lib/components/asset-viewer/actions/RestoreAction.svelte b/web/src/lib/components/asset-viewer/actions/RestoreAction.svelte deleted file mode 100644 index 5a160801ff..0000000000 --- a/web/src/lib/components/asset-viewer/actions/RestoreAction.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 8af6eed9d0..a520458f3d 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -5,9 +5,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset }; - [AssetAction.TRASH]: { asset: TimelineAsset }; - [AssetAction.DELETE]: { asset: TimelineAsset }; - [AssetAction.RESTORE]: { asset: TimelineAsset }; [AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; diff --git a/web/src/lib/components/share-page/IndividualSharedViewer.svelte b/web/src/lib/components/share-page/IndividualSharedViewer.svelte index a3cf76c78e..4641f1eaa6 100644 --- a/web/src/lib/components/share-page/IndividualSharedViewer.svelte +++ b/web/src/lib/components/share-page/IndividualSharedViewer.svelte @@ -22,6 +22,7 @@ import { t } from 'svelte-i18n'; import ControlAppBar from '../shared-components/ControlAppBar.svelte'; import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; interface Props { sharedLink: SharedLinkResponseDto; @@ -63,15 +64,20 @@ const handleAction = async (action: Action) => { switch (action.type) { - case AssetAction.ARCHIVE: - case AssetAction.DELETE: - case AssetAction.TRASH: { + case AssetAction.ARCHIVE: { await goto(Route.photos()); break; } // no default } }; + + const onAssetsDelete = async (assetIds: string[]) => { + // Only used for single asset shared link + if (assetIds.includes(assets[0].id)) { + await goto(Route.photos()); + } + }; {#if sharedLink?.allowUpload || assets.length > 1} @@ -132,6 +138,8 @@ {/if} {:else if assets.length === 1} + + {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} diff --git a/web/src/lib/components/shared-components/gallery-viewer/GalleryViewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/GalleryViewer.svelte index 29e7e6458c..0f2583867c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/GalleryViewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/GalleryViewer.svelte @@ -4,6 +4,7 @@ import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction } from '$lib/constants'; import Portal from '$lib/elements/Portal.svelte'; import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; @@ -285,9 +286,7 @@ const handleAction = async (action: Action) => { switch (action.type) { - case AssetAction.ARCHIVE: - case AssetAction.DELETE: - case AssetAction.TRASH: { + case AssetAction.ARCHIVE: { const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset; assets.splice( 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) => { if (assetInteraction.selectionActive) { handleSelectAssetCandidates(asset); @@ -338,6 +348,8 @@ updateSlidingWindow()} /> + + {#if assets.length > 0}
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte'; + import OnEvents from '$lib/components/OnEvents.svelte'; import { AssetAction } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte'; import { authManager } from '$lib/managers/auth-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 { handlePromiseError } from '$lib/utils'; 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 $effect(() => { const asset = assetViewerManager.asset; @@ -109,35 +117,20 @@ const handleRemoveFromAlbum = async (assetIds: string[]) => { timelineManager.removeAssets(assetIds); - if (!assetIds.includes(assetCursor.current.id)) { - return; + if (assetIds.includes(assetCursor.current.id)) { + 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) => { switch (action.type) { case removeAction: - case AssetAction.TRASH: - case AssetAction.RESTORE: - case AssetAction.DELETE: case AssetAction.ARCHIVE: case AssetAction.SET_VISIBILITY_LOCKED: case AssetAction.SET_VISIBILITY_TIMELINE: { // must update manager before performing any navigation timelineManager.removeAssets([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)); - + await navigateOrCloseViewer(action.asset.id); break; } // no default @@ -199,9 +192,19 @@ // no default } }; - const handleUndoDelete = async (assets: TimelineAsset[]) => { - timelineManager.upsertAssets(assets); - if (assets.length === 0) { + + const onAssetsDelete = async (assetIds: string[]) => { + 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; } @@ -234,6 +237,8 @@ }); + + {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} { }; }); +vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () { + return { + featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never, + }; +}); + describe('AssetService', () => { describe('getAssetActions', () => { beforeEach(() => { diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 3e629b413f..25fd0125c2 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -3,7 +3,9 @@ import { AssetMediaSize, AssetTypeEnum, AssetVisibility, + deleteAssets, getAssetInfo, + restoreAssets, runAssetJobs, updateAsset, type AssetJobsDto, @@ -15,12 +17,15 @@ import { mdiCogRefreshOutline, mdiContentCopy, mdiDatabaseRefreshOutline, + mdiDeleteForeverOutline, + mdiDeleteOutline, mdiDownload, mdiDownloadBox, mdiFaceRecognition, mdiHeadSyncOutline, mdiHeart, mdiHeartOutline, + mdiHistory, mdiImageRefreshOutline, mdiInformationOutline, mdiMagnifyMinusOutline, @@ -34,14 +39,18 @@ import { mdiTune, } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; import { ProjectionType } from '$lib/constants'; import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-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 AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; +import { showDeleteModal } from '$lib/stores/preferences.store'; import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils'; import { downloadUrl } from '$lib/utils'; @@ -96,6 +105,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = const sharedLink = getSharedLink(); const authUser = authManager.authenticated ? authManager.user : undefined; const isOwner = !!(authUser && authUser.id === asset.ownerId); + const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash; const Share: ActionItem = { title: $t('share'), @@ -242,6 +252,29 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = 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 = { title: $t('refresh_faces'), icon: mdiHeadSyncOutline, @@ -286,6 +319,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = Tag, TagPeople, Edit, + Delete, + PermanentlyDelete, + Restore, RefreshFacesJob, RefreshMetadataJob, 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 messages: Record = { [AssetJobName.RefreshFaces]: $t('refreshing_faces'), diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte index 21ebd40f37..7aa184a765 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,5 +1,4 @@