diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte
index cd743d0ed1..a58441f528 100644
--- a/web/src/lib/components/asset-viewer/AssetViewer.svelte
+++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte
@@ -73,7 +73,6 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
- onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
}
@@ -89,7 +88,6 @@
onAction,
onUndoDelete,
onClose,
- onRemoveFromAlbum,
onRandom,
}: Props = $props();
@@ -514,7 +512,6 @@
onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
- {onRemoveFromAlbum}
{isPlayingOriginalVideo}
{setPlayOriginalVideo}
/>
diff --git a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte
index 241ab6a4f2..2772e34f09 100644
--- a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte
+++ b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte
@@ -14,7 +14,6 @@
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.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 { languageManager } from '$lib/managers/language-manager.svelte';
@@ -38,34 +37,31 @@
interface Props {
asset: AssetResponseDto;
- album?: AlbumResponseDto | null;
+ album?: AlbumResponseDto;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
- onRemoveFromAlbum?: (assetIds: string[]) => void;
isPlayingOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
let {
asset,
- album = null,
+ album,
person = null,
stack = null,
preAction,
onAction,
onUndoDelete = undefined,
onClose,
- onRemoveFromAlbum,
isPlayingOriginalVideo = 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 { Cast } = $derived(getGlobalActions($t));
@@ -85,7 +81,7 @@
onAction: () => setPlayOriginalVideo(!isPlayingOriginalVideo),
});
- const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
+ const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }, album));
const sharedLink = getSharedLink();
@@ -146,9 +142,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 e3264a7e22..491a73968d 100644
--- a/web/src/lib/managers/event-manager.svelte.ts
+++ b/web/src/lib/managers/event-manager.svelte.ts
@@ -43,6 +43,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 1288aa6100..d6535c560a 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';
@@ -24,6 +26,7 @@ import {
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
+ mdiImageRemoveOutline,
mdiImageSearch,
mdiInformationOutline,
mdiMagnifyMinusOutline,
@@ -55,8 +58,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) });
@@ -71,6 +75,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,
@@ -96,13 +111,25 @@ 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 & { stackPrimaryAssetId?: string }) => {
+export const getAssetActions = (
+ $t: MessageFormatter,
+ asset: AssetResponseDto & { stackPrimaryAssetId?: string },
+ 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 smartSearchEnabled = featureFlagsManager.value.smartSearch;
const Share: ActionItem = {
@@ -181,6 +208,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,
@@ -310,6 +344,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
StopMotionPhoto,
PlaySlideshow,
AddToAlbum,
+ RemoveFromAlbum,
ZoomIn,
ZoomOut,
Copy,
@@ -400,6 +435,39 @@ 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;
+
+ 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 032a58ad52..5eaf51730d 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();
@@ -208,7 +214,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));
@@ -334,6 +340,7 @@
onSharedLinkDelete={refreshAlbum}
{onAlbumDelete}
{onAlbumAddAssets}
+ {onAlbumRemoveAssets}
{onAlbumShare}
{onAlbumUserUpdate}
onAlbumUserDelete={refreshAlbum}
@@ -457,7 +464,7 @@
{#if assetMultiSelectManager.selectionActive}
- {@const Actions = getAssetBulkActions($t)}
+ {@const Actions = getAssetBulkActions($t, album)}
@@ -493,9 +500,7 @@
{/if}
- {#if isOwned || assetMultiSelectManager.isAllUserOwned}
-
- {/if}
+
{#if assetMultiSelectManager.isAllUserOwned}
{/if}