diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte
index 1df6cce30..a857da1dd 100644
--- a/web/src/lib/components/photos-page/actions/stack-action.svelte
+++ b/web/src/lib/components/photos-page/actions/stack-action.svelte
@@ -1,13 +1,8 @@
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index 53847c581..8991c6915 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -18,6 +18,7 @@
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
+ import { stackAssets } from '$lib/utils/asset-utils';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { handlePromiseError } from '$lib/utils';
import { selectAllAssets } from '$lib/utils/asset-utils';
@@ -85,6 +86,13 @@
handlePromiseError(trashOrDelete(true));
};
+ const onStackAssets = async () => {
+ await stackAssets(Array.from($selectedAssets), (ids) => {
+ assetStore.removeAssets(ids);
+ dispatch('escape');
+ });
+ };
+
$: shortcutList = (() => {
if ($isSearchEnabled || $showAssetViewer) {
return [];
@@ -102,6 +110,7 @@
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
+ { shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
);
}
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte
index cfa3768bb..a1a8576a6 100644
--- a/web/src/lib/components/shared-components/show-shortcuts.svelte
+++ b/web/src/lib/components/shared-components/show-shortcuts.svelte
@@ -25,6 +25,7 @@
actions: [
{ key: ['f'], action: 'Favorite or unfavorite photo' },
{ key: ['i'], action: 'Show or hide info' },
+ { key: ['s'], action: 'Stack selected photos' },
{ key: ['⇧', 'a'], action: 'Archive or unarchive photo' },
{ key: ['⇧', 'd'], action: 'Download' },
{ key: ['Space'], action: 'Play or pause video' },
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index 532479912..a4255b75d 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -11,6 +11,7 @@ import {
createAlbum,
defaults,
getDownloadInfo,
+ updateAssets,
type AssetResponseDto,
type AssetTypeEnum,
type DownloadInfoDto,
@@ -270,6 +271,44 @@ export const getSelectedAssets = (assets: Set, user: UserRespo
return ids;
};
+export async function stackAssets(assets: Array, onStack: (ds: string[]) => void) {
+ try {
+ const parent = assets.at(0);
+ if (!parent) {
+ return;
+ }
+
+ const children = assets.slice(1);
+ const ids = children.map(({ id }) => id);
+
+ if (children.length > 0) {
+ await updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } });
+ }
+
+ let childrenCount = parent.stackCount || 1;
+ for (const asset of children) {
+ asset.stackParentId = parent.id;
+ // Add grand-children's count to new parent
+ childrenCount += asset.stackCount || 1;
+ // Reset children stack info
+ asset.stackCount = null;
+ asset.stack = [];
+ }
+
+ parent.stackCount = childrenCount;
+
+ notificationController.show({
+ message: `Stacked ${ids.length + 1} assets`,
+ type: NotificationType.Info,
+ timeout: 1500,
+ });
+
+ onStack(ids);
+ } catch (error) {
+ handleError(error, `Unable to stack`);
+ }
+}
+
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {
if (get(isSelectingAllAssets)) {
// Selection is already ongoing