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 1df6cce30a..a857da1dd3 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 53847c5814..8991c69152 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 cfa3768bb5..a1a8576a69 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 532479912a..a4255b75df 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