From 653b17669b999146ddae4731f62493bb9ed6fa76 Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Fri, 19 Jun 2026 13:03:51 +0200 Subject: [PATCH 1/2] refactor: remove assets from album action --- .../asset-viewer/AssetViewer.svelte | 3 - .../asset-viewer/AssetViewerNavBar.svelte | 14 +--- .../timeline/TimelineAssetViewer.svelte | 10 ++- .../actions/RemoveFromAlbumAction.svelte | 61 ---------------- web/src/lib/managers/event-manager.svelte.ts | 1 + web/src/lib/services/asset.service.ts | 73 ++++++++++++++++++- .../[[assetId=id]]/+page.svelte | 17 +++-- 7 files changed, 94 insertions(+), 85 deletions(-) delete mode 100644 web/src/lib/components/timeline/actions/RemoveFromAlbumAction.svelte diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index f63b919633..a33c796658 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -71,7 +71,6 @@ onAction?: OnAction; onUndoDelete?: OnUndoDelete; onClose?: (assetId: string) => void; - onRemoveFromAlbum?: (assetIds: string[]) => void; onRandom?: () => Promise<{ id: string } | undefined>; } @@ -87,7 +86,6 @@ onAction, onUndoDelete, onClose, - onRemoveFromAlbum, onRandom, }: Props = $props(); @@ -505,7 +503,6 @@ {onUndoDelete} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined} - {onRemoveFromAlbum} {playOriginalVideo} {setPlayOriginalVideo} /> diff --git a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte index 75504765e2..3b3baea310 100644 --- a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte @@ -18,7 +18,6 @@ import LoadingDots from '$lib/components/LoadingDots.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte'; - import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; @@ -51,7 +50,7 @@ interface Props { asset: AssetResponseDto; - album?: AlbumResponseDto | null; + album?: AlbumResponseDto; person?: PersonResponseDto | null; stack?: StackResponseDto | null; showSlideshow?: boolean; @@ -60,14 +59,13 @@ onUndoDelete?: OnUndoDelete; onPlaySlideshow: () => void; onClose?: () => void; - onRemoveFromAlbum?: (assetIds: string[]) => void; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; } let { asset, - album = null, + album, person = null, stack = null, showSlideshow = false, @@ -76,13 +74,11 @@ onUndoDelete = undefined, onPlaySlideshow, onClose, - onRemoveFromAlbum, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id); - const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id); const isLocked = $derived(asset.visibility === AssetVisibility.Locked); const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); @@ -96,7 +92,7 @@ shortcuts: [{ key: 'Escape' }], }); - const Actions = $derived(getAssetActions($t, asset)); + const Actions = $derived(getAssetActions($t, asset, album)); const sharedLink = getSharedLink(); @@ -159,9 +155,7 @@ {/if} - {#if album && (isOwner || isAlbumOwner)} - - {/if} + {#if isOwner} diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 2055afb445..aadef6dc81 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -1,6 +1,7 @@ + + {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} {/await} diff --git a/web/src/lib/components/timeline/actions/RemoveFromAlbumAction.svelte b/web/src/lib/components/timeline/actions/RemoveFromAlbumAction.svelte deleted file mode 100644 index cc4766150b..0000000000 --- a/web/src/lib/components/timeline/actions/RemoveFromAlbumAction.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -{#if menuItem} - -{:else} - -{/if} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 15de65ca3f..41e879e2f6 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -40,6 +40,7 @@ export type Events = { AssetsTag: [string[]]; AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }]; + AlbumRemoveAssets: [{ assetIds: string[]; albumIds: string[] }]; AlbumCreate: [AlbumResponseDto]; AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 8804c23ec2..4906175eea 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -4,8 +4,10 @@ import { AssetTypeEnum, AssetVisibility, getAssetInfo, + removeAssetFromAlbum, runAssetJobs, updateAsset, + type AlbumResponseDto, type AssetJobsDto, type AssetResponseDto, } from '@immich/sdk'; @@ -22,6 +24,7 @@ import { mdiHeart, mdiHeartOutline, mdiImageRefreshOutline, + mdiImageRemoveOutline, mdiInformationOutline, mdiMagnifyMinusOutline, mdiMagnifyPlusOutline, @@ -46,8 +49,9 @@ import { downloadUrl } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; -export const getAssetBulkActions = ($t: MessageFormatter) => { +export const getAssetBulkActions = ($t: MessageFormatter, album?: AlbumResponseDto) => { const ownedAssets = assetMultiSelectManager.ownedAssets; + const isAlbumOwner = album?.albumUsers[0].user.id === authManager.user.id; const onAction = async (name: AssetJobName) => { await handleRunAssetJob({ name, assetIds: ownedAssets.map(({ id }) => id) }); @@ -62,6 +66,17 @@ export const getAssetBulkActions = ($t: MessageFormatter) => { modalManager.show(AssetAddToAlbumModal, { assetIds: assetMultiSelectManager.assets.map((asset) => asset.id) }), }; + const RemoveFromAlbum: ActionItem = { + title: $t('remove_from_album'), + icon: mdiImageRemoveOutline, + $if: () => !!album && (isAlbumOwner || assetMultiSelectManager.isAllUserOwned), + onAction: () => + handleBulkRemoveAssetsFromAlbum( + assetMultiSelectManager.assets.map((asset) => asset.id), + album!, + ), + }; + const RefreshFacesJob: ActionItem = { title: $t('refresh_faces'), icon: mdiHeadSyncOutline, @@ -87,13 +102,21 @@ export const getAssetBulkActions = ($t: MessageFormatter) => { $if: () => ownedAssets.every((asset) => asset.isVideo), }; - return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; + return { + AddToAlbum, + RemoveFromAlbum, + RefreshFacesJob, + RefreshMetadataJob, + RegenerateThumbnailJob, + TranscodeVideoJob, + }; }; -export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { +export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto, album?: AlbumResponseDto) => { const sharedLink = getSharedLink(); const authUser = authManager.authenticated ? authManager.user : undefined; const isOwner = !!(authUser && authUser.id === asset.ownerId); + const isAlbumOwner = !!(authUser && authUser.id === album?.albumUsers[0].user.id); const Share: ActionItem = { title: $t('share'), @@ -164,6 +187,13 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }), }; + const RemoveFromAlbum: ActionItem = { + title: $t('remove_from_album'), + icon: mdiImageRemoveOutline, + $if: () => !!album && (isOwner || isAlbumOwner), + onAction: () => handleRemoveAssetsFromAlbum([asset.id], album!), + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, @@ -270,6 +300,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = PlayMotionPhoto, StopMotionPhoto, AddToAlbum, + RemoveFromAlbum, ZoomIn, ZoomOut, Copy, @@ -357,6 +388,42 @@ const handleUnfavorite = async (asset: AssetResponseDto) => { } }; +const handleBulkRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => { + const $t = await getFormatter(); + + const isConfirmed = await modalManager.showDialog({ + prompt: $t('remove_assets_album_confirmation', { values: { count: assetIds.length } }), + }); + + if (!isConfirmed) { + return; + } + + await handleRemoveAssetsFromAlbum(assetIds, album); + assetMultiSelectManager.clear(); +}; + +const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => { + const $t = await getFormatter(); + + try { + const results = await removeAssetFromAlbum({ + id: album.id, + bulkIdsDto: { ids: assetIds }, + }); + + const count = results.filter(({ success }) => success).length; + if (count !== assetIds.length) { + throw new Error('Not all assets could be removed'); + } + + toastManager.primary($t('assets_removed_count', { values: { count } })); + eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] }); + } catch (error) { + handleError(error, $t('errors.error_removing_assets_from_album')); + } +}; + const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => { const messages: Record = { [AssetJobName.RefreshFaces]: $t('refreshing_faces'), diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 42072c122b..adf55391ac 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,7 +19,6 @@ import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte'; import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte'; - import RemoveFromAlbum from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte'; import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; @@ -78,6 +77,7 @@ import type { PageData } from './$types'; import AlbumDescription from './AlbumDescription.svelte'; import AlbumTitle from './AlbumTitle.svelte'; + import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; interface Props { data: PageData; @@ -156,6 +156,12 @@ assetMultiSelectManager.clear(); }; + const onAlbumRemoveAssets = async ({ assetIds, albumIds }: { assetIds: string[]; albumIds: string[] }) => { + if (albumIds.includes(album.id)) { + await handleRemoveAssets(assetIds); + } + }; + const handleRemoveAssets = async (assetIds: string[]) => { timelineManager.removeAssets(assetIds); await refreshAlbum(); @@ -206,7 +212,7 @@ } }); - let album = $derived(data.album); + let album = $state(data.album); let albumId = $derived(album.id); const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)); @@ -330,6 +336,7 @@ onSharedLinkDelete={refreshAlbum} {onAlbumDelete} {onAlbumAddAssets} + {onAlbumRemoveAssets} {onAlbumShare} {onAlbumUserUpdate} onAlbumUserDelete={refreshAlbum} @@ -453,7 +460,7 @@ {#if assetMultiSelectManager.selectionActive} - {@const Actions = getAssetBulkActions($t)} + {@const Actions = getAssetBulkActions($t, album)} @@ -489,9 +496,7 @@ {/if} - {#if isOwned || assetMultiSelectManager.isAllUserOwned} - - {/if} + {#if assetMultiSelectManager.isAllUserOwned} {/if} From 1fe56700fa646757e7db4a2a0f4d5c2a0d3b7e2f Mon Sep 17 00:00:00 2001 From: Mees Frensel Date: Mon, 22 Jun 2026 10:55:07 +0200 Subject: [PATCH 2/2] don't check and throw --- web/src/lib/services/asset.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index 4906175eea..529bdce2ef 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -413,9 +413,6 @@ const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumRespo }); const count = results.filter(({ success }) => success).length; - if (count !== assetIds.length) { - throw new Error('Not all assets could be removed'); - } toastManager.primary($t('assets_removed_count', { values: { count } })); eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });