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}