diff --git a/i18n/en.json b/i18n/en.json
index 4157e5038..f9a5f14ec 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -35,7 +35,7 @@
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",
- "add_upload_to_stack": "Add upload to stack",
+ "add_upload_to_stack": "Upload and {isStack, select, true {add to stack} other {create stack}}",
"add_url": "Add URL",
"add_user": "Add user",
"added_to_archive": "Added to archive",
@@ -1806,6 +1806,7 @@
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
+ "removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
@@ -2105,6 +2106,7 @@
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
+ "stack_created": "Stack created",
"stack_duplicates": "Stack duplicates",
"stack_selected_photos": "Stack selected photos",
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte
index 26b824c00..3b69f8b2f 100644
--- a/web/src/lib/components/asset-viewer/AssetViewer.svelte
+++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte
@@ -103,7 +103,7 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
- let stack: StackResponseDto | null = $state(null);
+ let stack: StackResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
@@ -128,7 +128,7 @@
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
- stack = null;
+ stack = undefined;
}
};
@@ -159,6 +159,24 @@
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
+ const onStackCreate = (createdStack: StackResponseDto) => {
+ if (createdStack.assets.map((a) => a.id).includes(asset.id)) {
+ stack = createdStack;
+ }
+ };
+
+ const onStackUpdate = (updatedStack: StackResponseDto) => {
+ if (stack?.id !== updatedStack.id) {
+ return;
+ }
+
+ stack = updatedStack;
+ if (!stack.assets.map((a) => a.id).includes(asset.id)) {
+ // current asset was removed from stack, go to primary
+ cursor.current = stack.assets[0];
+ }
+ };
+
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -334,18 +352,6 @@
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
- case AssetAction.REMOVE_ASSET_FROM_STACK: {
- stack = action.stack;
- if (stack) {
- cursor.current = stack.assets[0];
- }
- break;
- }
- case AssetAction.STACK:
- case AssetAction.SET_STACK_PRIMARY_ASSET: {
- stack = action.stack;
- break;
- }
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
cursor.current = { ...asset, people: assetInfo.people };
@@ -362,10 +368,6 @@
};
break;
}
- case AssetAction.UNSTACK: {
- closeViewer();
- break;
- }
// no default
}
@@ -488,7 +490,7 @@
-
+ closeViewer()} {onStackUpdate} />
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
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 SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
- 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 { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
@@ -20,6 +15,7 @@
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
+ import { getStackActions } from '$lib/services/stack.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -39,7 +35,7 @@
asset: AssetResponseDto;
album?: AlbumResponseDto;
person?: PersonResponseDto | null;
- stack?: StackResponseDto | null;
+ stack?: StackResponseDto;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
@@ -52,7 +48,7 @@
asset,
album,
person = null,
- stack = null,
+ stack,
preAction,
onAction,
onUndoDelete = undefined,
@@ -82,6 +78,7 @@
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }, album));
+ const StackActions = $derived(getStackActions($t, stack, asset));
const sharedLink = getSharedLink();
@@ -144,19 +141,12 @@
- {#if isOwner}
-
- {#if stack}
-
-
- {#if stack?.primaryAssetId !== asset.id}
-
- {#if stack?.assets?.length > 2}
-
- {/if}
- {/if}
- {/if}
- {/if}
+
+
+
+
+
+
{#if album}
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
diff --git a/web/src/lib/components/asset-viewer/actions/AddToStackAction.svelte b/web/src/lib/components/asset-viewer/actions/AddToStackAction.svelte
deleted file mode 100644
index 4337d8cf5..000000000
--- a/web/src/lib/components/asset-viewer/actions/AddToStackAction.svelte
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte b/web/src/lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte
deleted file mode 100644
index adc1dd177..000000000
--- a/web/src/lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte b/web/src/lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte
deleted file mode 100644
index e0589b01e..000000000
--- a/web/src/lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte b/web/src/lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte
deleted file mode 100644
index 696fb3165..000000000
--- a/web/src/lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
diff --git a/web/src/lib/components/asset-viewer/actions/UnstackAction.svelte b/web/src/lib/components/asset-viewer/actions/UnstackAction.svelte
deleted file mode 100644
index b328b816f..000000000
--- a/web/src/lib/components/asset-viewer/actions/UnstackAction.svelte
+++ /dev/null
@@ -1,26 +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 8af6eed9d..6cfd68dc3 100644
--- a/web/src/lib/components/asset-viewer/actions/action.ts
+++ b/web/src/lib/components/asset-viewer/actions/action.ts
@@ -1,4 +1,4 @@
-import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
+import type { AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -8,10 +8,6 @@ type ActionMap = {
[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 };
- [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte
index aadef6dc8..d718d2dd8 100644
--- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte
+++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte
@@ -10,7 +10,6 @@
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';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleErrorAsync } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
@@ -155,52 +154,6 @@
timelineManager.upsertAssets([action.asset]);
break;
}
-
- case AssetAction.STACK: {
- updateStackedAssetInTimeline(timelineManager, {
- stack: action.stack,
- toDeleteIds: action.stack.assets
- .filter((asset) => asset.id !== action.stack.primaryAssetId)
- .map((asset) => asset.id),
- });
- break;
- }
-
- case AssetAction.UNSTACK: {
- updateUnstackedAssetInTimeline(timelineManager, action.assets);
- break;
- }
- case AssetAction.REMOVE_ASSET_FROM_STACK: {
- timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
- if (action.stack) {
- //Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
- updateUnstackedAssetInTimeline(
- timelineManager,
- action.stack.assets.map((asset) => toTimelineAsset(asset)),
- );
- updateStackedAssetInTimeline(timelineManager, {
- stack: action.stack,
- toDeleteIds: action.stack.assets
- .filter((asset) => asset.id !== action.stack?.primaryAssetId)
- .map((asset) => asset.id),
- });
- }
- break;
- }
- case AssetAction.SET_STACK_PRIMARY_ASSET: {
- //Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
- updateUnstackedAssetInTimeline(
- timelineManager,
- action.stack.assets.map((asset) => toTimelineAsset(asset)),
- );
- updateStackedAssetInTimeline(timelineManager, {
- stack: action.stack,
- toDeleteIds: action.stack.assets
- .filter((asset) => asset.id !== action.stack.primaryAssetId)
- .map((asset) => asset.id),
- });
- break;
- }
// no default
}
};
diff --git a/web/src/lib/components/timeline/actions/StackAction.svelte b/web/src/lib/components/timeline/actions/StackAction.svelte
deleted file mode 100644
index be49ad5e5..000000000
--- a/web/src/lib/components/timeline/actions/StackAction.svelte
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-{#if unstack}
-
-{:else}
-
-{/if}
diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
index 32d32083c..3c689f456 100644
--- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
+++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
@@ -15,12 +15,13 @@
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
+ import { handleStack } from '$lib/services/stack.service';
import { keyboardManager } from '$lib/stores/keyboard-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
- import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
- import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
+ import { deleteAssets } from '$lib/utils/actions';
+ import { archiveAssets, selectAllAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { isModalOpen, modalManager } from '@immich/ui';
@@ -59,10 +60,7 @@
};
const onStackAssets = async () => {
- const result = await stackAssets(assetInteraction.assets);
-
- updateStackedAssetInTimeline(timelineManager, result);
-
+ await handleStack(assetInteraction.assets.map((asset) => asset.id));
onEscape?.();
};
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts
index b6664bfa3..2c3e7b889 100644
--- a/web/src/lib/constants.ts
+++ b/web/src/lib/constants.ts
@@ -6,10 +6,6 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
- STACK = 'stack',
- UNSTACK = 'unstack',
- SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
- REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts
index 90079fb94..da28453aa 100644
--- a/web/src/lib/managers/event-manager.svelte.ts
+++ b/web/src/lib/managers/event-manager.svelte.ts
@@ -11,6 +11,7 @@ import type {
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
+ StackResponseDto,
AdminConfigDto,
TagResponseDto,
UserAdminResponseDto,
@@ -65,6 +66,12 @@ export type Events = {
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
+ StackCreate: [StackResponseDto];
+ /** Unstacked, with assets to handle */
+ StackDelete: [{ id: string; assets: AssetResponseDto[] }];
+ /** Contains the new stack. If any assets are removed, AssetUpdate events are emitted for each. */
+ StackUpdate: [StackResponseDto];
+
TagCreate: [TagResponseDto];
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
index dac5644c3..bc67e8365 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
@@ -18,6 +18,7 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { userPreferencesManager } from '$lib/managers/user-preferences-manager.svelte';
+import { updateStackedAssetInTimeline } from '$lib/utils/actions';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
getOrderingDate,
@@ -122,6 +123,33 @@ export class TimelineManager extends VirtualScrollManager {
}
},
AssetsUnarchive: (assets) => this.upsertAssets(assets),
+ StackCreate: (stack) => {
+ if (this.#options.withStacked) {
+ updateStackedAssetInTimeline(this, stack);
+ }
+ },
+ StackDelete: ({ assets }) => {
+ if (!this.#options.withStacked) {
+ return;
+ }
+ this.update(
+ assets.map((asset) => asset.id),
+ (asset) => (asset.stack = null),
+ );
+ this.upsertAssets(assets.map((asset) => toTimelineAsset(asset)));
+ },
+ StackUpdate: (stack) => {
+ if (!this.#options.withStacked) {
+ return;
+ }
+ // unstack and re-stack
+ this.update(
+ stack.assets.map((asset) => asset.id),
+ (asset) => (asset.stack = null),
+ );
+ this.upsertAssets(stack.assets.map((asset) => toTimelineAsset(asset)));
+ updateStackedAssetInTimeline(this, stack);
+ },
}),
);
}
diff --git a/web/src/lib/services/stack.service.ts b/web/src/lib/services/stack.service.ts
new file mode 100644
index 000000000..cd2f20462
--- /dev/null
+++ b/web/src/lib/services/stack.service.ts
@@ -0,0 +1,215 @@
+import {
+ createStack,
+ deleteAssets,
+ deleteStacks,
+ getStack,
+ removeAssetFromStack,
+ updateStack,
+ type AssetResponseDto,
+ type AssetStackResponseDto,
+ type StackResponseDto,
+} from '@immich/sdk';
+import { modalManager, toastManager, type ActionItem } from '@immich/ui';
+import {
+ mdiImageCheckOutline,
+ mdiImageMinusOutline,
+ mdiImageMultipleOutline,
+ mdiImageOffOutline,
+ mdiPinOutline,
+ mdiUploadMultiple,
+} from '@mdi/js';
+import { type MessageFormatter } from 'svelte-i18n';
+import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
+import { authManager } from '$lib/managers/auth-manager.svelte';
+import { eventManager } from '$lib/managers/event-manager.svelte';
+import { openFileUploadDialog } from '$lib/utils/file-uploader';
+import { handleError } from '$lib/utils/handle-error';
+import { getFormatter } from '$lib/utils/i18n';
+import { navigate } from '$lib/utils/navigation';
+
+export const getStackBulkActions = ($t: MessageFormatter) => {
+ const Stack: ActionItem = {
+ title: $t('stack'),
+ icon: mdiImageMultipleOutline,
+ $if: () => assetMultiSelectManager.ownedAssets.length > 1,
+ onAction: () => handleStack(assetMultiSelectManager.ownedAssets.map((asset) => asset.id)),
+ };
+
+ const Unstack: ActionItem = {
+ title: $t('unstack'),
+ icon: mdiImageOffOutline,
+ $if: () => assetMultiSelectManager.ownedAssets.every((asset) => !!asset.stack),
+ onAction: () => handleDeleteStacks(assetMultiSelectManager.ownedAssets.map(({ stack }) => stack!)),
+ };
+
+ return { Stack, Unstack };
+};
+
+export const getStackActions = ($t: MessageFormatter, stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
+ const authUser = authManager.authenticated ? authManager.user : undefined;
+ const isAssetOwner = !!(authUser && authUser.id === asset.ownerId);
+ const validStack = !!stack && isAssetOwner;
+
+ const AddUploads: ActionItem = {
+ title: $t('add_upload_to_stack', { values: { isStack: !!stack } }),
+ icon: mdiUploadMultiple,
+ $if: () => isAssetOwner,
+ onAction: () => handleAddUploadToStack(stack, asset),
+ };
+
+ const KeepThisDeleteOthers: ActionItem = {
+ title: $t('keep_this_delete_others'),
+ icon: mdiPinOutline,
+ $if: () => validStack,
+ onAction: () => handleKeepThisDeleteOthers(stack!, asset),
+ };
+
+ const RemoveAsset: ActionItem = {
+ title: $t('viewer_remove_from_stack'),
+ icon: mdiImageMinusOutline,
+ $if: () => validStack && stack?.primaryAssetId !== asset.id && stack?.assets?.length > 2,
+ onAction: () => handleRemoveFromStack(stack!, asset),
+ };
+
+ const SetPrimaryAsset: ActionItem = {
+ title: $t('set_stack_primary_asset'),
+ icon: mdiImageCheckOutline,
+ $if: () => validStack && stack!.primaryAssetId !== asset.id,
+ onAction: () => handleSetPrimaryAsset(stack!, asset),
+ };
+
+ const Unstack: ActionItem = {
+ title: $t('unstack'),
+ icon: mdiImageOffOutline,
+ $if: () => validStack,
+ onAction: () => handleDeleteStack(stack!),
+ };
+
+ return { AddUploads, KeepThisDeleteOthers, RemoveAsset, SetPrimaryAsset, Unstack };
+};
+
+const handleAddUploadToStack = async (stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
+ const $t = await getFormatter();
+
+ const newAssetIds = await openFileUploadDialog({ multiple: true });
+ // Including the old stack's primary asset ID ensures that all assets of the
+ // old stack are automatically included in the new stack.
+ const primaryAssetId = stack?.primaryAssetId ?? asset.id;
+
+ // First asset in the list will become the new primary asset.
+ const assetIds = [primaryAssetId, ...newAssetIds];
+
+ try {
+ const stack = await createStack({ stackCreateDto: { assetIds } });
+
+ toastManager.primary($t('stack_created'));
+ eventManager.emit('StackCreate', stack);
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_stack_assets'));
+ }
+};
+
+const handleKeepThisDeleteOthers = async (stack: StackResponseDto, asset: AssetResponseDto) => {
+ const $t = await getFormatter();
+
+ const isConfirmed = await modalManager.showDialog({
+ title: $t('keep_this_delete_others'),
+ prompt: $t('confirm_keep_this_delete_others'),
+ confirmText: $t('delete_others'),
+ });
+
+ if (!isConfirmed) {
+ return;
+ }
+
+ try {
+ const assetsToDeleteIds = stack.assets.filter((a) => a.id !== asset.id).map((asset) => asset.id);
+ await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
+ await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
+
+ toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
+ eventManager.emit('StackDelete', { id: stack.id, assets: [asset] });
+ eventManager.emit('AssetUpdate', { ...asset, stack: null });
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_keep_this_delete_others'));
+ }
+};
+
+const handleRemoveFromStack = async (stack: StackResponseDto, asset: AssetResponseDto) => {
+ const $t = await getFormatter();
+
+ try {
+ await removeAssetFromStack({ id: stack.id, assetId: asset.id });
+ const updatedStack = {
+ ...stack,
+ assets: stack.assets.filter((a) => a.id !== asset.id),
+ };
+
+ toastManager.primary($t('removed_from_stack'));
+ eventManager.emit('AssetUpdate', asset);
+ eventManager.emit('StackUpdate', updatedStack);
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_unstack_assets'));
+ }
+};
+
+const handleSetPrimaryAsset = async (stack: StackResponseDto, asset: AssetResponseDto) => {
+ const $t = await getFormatter();
+
+ try {
+ const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
+
+ eventManager.emit('StackUpdate', updatedStack);
+ } catch (error) {
+ handleError(error, $t('errors.something_went_wrong'));
+ }
+};
+
+export const handleStack = async (assetIds: string[]) => {
+ const $t = await getFormatter();
+
+ try {
+ const stack = await createStack({ stackCreateDto: { assetIds } });
+
+ toastManager.primary({
+ description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
+ button: {
+ label: $t('view_stack'),
+ onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
+ },
+ });
+ eventManager.emit('StackCreate', stack);
+ assetMultiSelectManager.clear();
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_stack_assets'));
+ }
+};
+
+export const handleDeleteStack = async (stack: StackResponseDto) => {
+ const $t = await getFormatter();
+
+ try {
+ await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
+
+ const assetIds = stack.assets.map((asset) => asset.id);
+
+ toastManager.primary($t('unstacked_assets_count', { values: { count: assetIds.length } }));
+ eventManager.emit('StackDelete', stack);
+
+ return assetIds;
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_unstack_assets'));
+ }
+};
+
+const handleDeleteStacks = async (assetStacks: AssetStackResponseDto[]) => {
+ const $t = await getFormatter();
+
+ try {
+ const stacks = await Promise.all(assetStacks.map((stack) => getStack(stack)));
+ await Promise.all(stacks.map((stack) => handleDeleteStack(stack)));
+ } catch (error) {
+ handleError(error, $t('errors.failed_to_unstack_assets'));
+ }
+ assetMultiSelectManager.clear();
+};
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index ed5011319..968e62565 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -1,10 +1,9 @@
-import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
+import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, type StackResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
-import type { StackResponse } from '$lib/utils/asset-utils';
import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void;
@@ -14,8 +13,6 @@ export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) =
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
-export type OnStack = (result: StackResponse) => void;
-export type OnUnstack = (assets: TimelineAsset[]) => void;
export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
@@ -61,45 +58,18 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
/**
* 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
- * and removes any assets from the timeline that are marked for deletion.
- *
- * @param {TimelineManager} timelineManager - The timeline manager to update.
- * @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
+ * and removes the non-primary assets from the timeline.
*/
-export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
- if (stack === undefined) {
- return;
- }
+export function updateStackedAssetInTimeline(timelineManager: TimelineManager, stack: StackResponseDto) {
+ timelineManager.update([stack.primaryAssetId], (asset) => {
+ asset.stack = {
+ id: stack.id,
+ primaryAssetId: stack.primaryAssetId,
+ assetCount: stack.assets.length,
+ };
+ });
- timelineManager.update(
- [stack.primaryAssetId],
- (asset) =>
- (asset.stack = {
- id: stack.id,
- primaryAssetId: stack.primaryAssetId,
- assetCount: stack.assets.length,
- }),
+ timelineManager.removeAssets(
+ stack.assets.filter((asset) => asset.id !== stack.primaryAssetId).map((asset) => asset.id),
);
-
- timelineManager.removeAssets(toDeleteIds);
-}
-
-/**
- * Update the timeline manager to reflect the unstacked state of assets.
- * This function updates the stack property of each asset to undefined, effectively unstacking them.
- * It also adds the unstacked assets back to the timeline manager.
- *
- * @param timelineManager - The timeline manager to update.
- * @param assets - The array of asset response DTOs to update in the timeline manager.
- */
-export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
- timelineManager.update(
- assets.map((asset) => asset.id),
- (asset) => {
- asset.stack = null;
- return { remove: false };
- },
- );
-
- timelineManager.upsertAssets(assets);
}
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index e39a4f881..d29527fb2 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -1,12 +1,8 @@
import {
AssetVisibility,
bulkTagAssets,
- createStack,
- deleteAssets,
- deleteStacks,
getBaseUrl,
getDownloadInfo,
- getStack,
untagAssets,
updateAsset,
updateAssets,
@@ -14,7 +10,6 @@ import {
type AssetTypeEnum,
type DownloadInfoDto,
type ExifResponseDto,
- type StackResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
@@ -284,84 +279,6 @@ export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserRes
return ids;
};
-export type StackResponse = {
- stack?: StackResponseDto;
- toDeleteIds: string[];
-};
-
-export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise => {
- if (assets.length < 2) {
- return { stack: undefined, toDeleteIds: [] };
- }
-
- const $t = get(t);
-
- try {
- const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
- if (showNotification) {
- toastManager.primary({
- description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
- button: {
- label: $t('view_stack'),
- onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
- },
- });
- }
-
- return {
- stack,
- toDeleteIds: assets.slice(1).map((asset) => asset.id),
- };
- } catch (error) {
- handleError(error, $t('errors.failed_to_stack_assets'));
- return { stack: undefined, toDeleteIds: [] };
- }
-};
-
-export const deleteStack = async (stackIds: string[]) => {
- const ids = [...new Set(stackIds)];
- if (ids.length === 0) {
- return;
- }
-
- const $t = get(t);
-
- try {
- const stacks = await Promise.all(ids.map((id) => getStack({ id })));
- const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0);
-
- await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
-
- toastManager.primary($t('unstacked_assets_count', { values: { count } }));
-
- const assets = stacks.flatMap((stack) => stack.assets);
- for (const asset of assets) {
- asset.stack = null;
- }
-
- return assets;
- } catch (error) {
- handleError(error, $t('errors.failed_to_unstack_assets'));
- }
-};
-
-export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: StackResponseDto) => {
- const $t = get(t);
-
- try {
- const assetsToDeleteIds = stack.assets.filter((asset) => asset.id !== keepAsset.id).map((asset) => asset.id);
- await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
- await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
-
- toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
-
- keepAsset.stack = null;
- return keepAsset;
- } catch (error) {
- handleError(error, $t('errors.failed_to_keep_this_delete_others'));
- }
-};
-
export const selectAllAssets = async (timelineManager: TimelineManager, assetInteraction: AssetMultiSelectManager) => {
if (assetInteraction.selectAll) {
// Selection is already ongoing
diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte
index 7bc4e4277..6b3760d8a 100644
--- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte
+++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/MapTimelinePanel.svelte
@@ -13,7 +13,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
- import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -22,13 +21,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
+ import { getStackBulkActions } from '$lib/services/stack.service';
import { mapSettings } from '$lib/stores/preferences.store';
- import {
- updateStackedAssetInTimeline,
- updateUnstackedAssetInTimeline,
- type OnLink,
- type OnUnlink,
- } from '$lib/utils/actions';
+ import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
@@ -46,7 +41,6 @@
let timelineManager = $state() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
- let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -89,6 +83,7 @@
: AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
+ withStacked: true,
assetFilter: selectedClusterIds,
});
@@ -117,12 +112,14 @@
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
+ withStacked
/>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
+ {@const StackActions = getStackBulkActions($t)}
@@ -139,13 +136,8 @@
- {#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
- updateStackedAssetInTimeline(timelineManager, result)}
- onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
- />
- {/if}
+
+
{#if isLinkActionAvailable}
timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
+ onArchive={(ids, visibility) =>
+ timelineManager.update(ids, (asset) => {
+ asset.visibility = visibility;
+ })}
/>
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index c95dd86f6..d589fa77e 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
- import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -26,13 +25,9 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
+ import { getStackBulkActions } from '$lib/services/stack.service';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
- import {
- updateStackedAssetInTimeline,
- updateUnstackedAssetInTimeline,
- type OnLink,
- type OnUnlink,
- } from '$lib/utils/actions';
+ import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -46,7 +41,6 @@
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
let selectedAssets = $derived(assetMultiSelectManager.assets);
- let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -117,6 +111,7 @@
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
+ {@const StackActions = getStackBulkActions($t)}
@@ -131,13 +126,8 @@
- {#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
- updateStackedAssetInTimeline(timelineManager, result)}
- onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
- />
- {/if}
+
+
{#if isLinkActionAvailable}
{
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -108,6 +102,7 @@
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
+ {@const StackActions = getStackBulkActions($t)}
@@ -122,13 +117,8 @@
- {#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
- updateStackedAssetInTimeline(timelineManager, result)}
- onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
- />
- {/if}
+
+
{#if isLinkActionAvailable}