From fe3eb7d865d6f2acbf8af25e37a604617b3b1b52 Mon Sep 17 00:00:00 2001 From: Yaros Date: Thu, 16 Jul 2026 17:53:02 +0200 Subject: [PATCH] feat(web): undo archive from toast (#27061) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- pnpm-lock.yaml | 10 ++-- pnpm-workspace.yaml | 2 +- web/package.json | 2 +- .../asset-viewer/AssetViewer.svelte | 13 +++- .../shared-components/map/Map.svelte | 4 +- web/src/lib/managers/event-manager.svelte.ts | 3 + .../timeline-manager.svelte.ts | 12 +++- web/src/lib/utils/asset-utils.ts | 59 ++++++++++++++++--- web/src/lib/utils/timeline-util.ts | 5 +- .../[[assetId=id]]/+page.svelte | 1 + 10 files changed, 91 insertions(+), 20 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6cebb0d5e..3c83b6ea41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -786,8 +786,8 @@ importers: specifier: workspace:* version: link:../packages/sdk '@immich/ui': - specifier: ^0.81.1 - version: 0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0)) + specifier: ^0.83.0 + version: 0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0)) '@mapbox/mapbox-gl-rtl-text': specifier: 0.4.0 version: 0.4.0 @@ -3250,8 +3250,8 @@ packages: resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} hasBin: true - '@immich/ui@0.81.1': - resolution: {integrity: sha512-7g173hArs7OS5CfHUG+ZJVQp1iCvFZzBmm+f2uH1WChlFdcwty5DLilYrDBszWuPIvu8wTX2AXTneS/KYBCUxw==} + '@immich/ui@0.83.0': + resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==} peerDependencies: '@sveltejs/kit': ^2.13.0 svelte: ^5.0.0 @@ -16111,7 +16111,7 @@ snapshots: pg-connection-string: 2.13.0 postgres: 3.4.9 - '@immich/ui@0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))': + '@immich/ui@0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))': dependencies: '@internationalized/date': 3.12.2 '@mdi/js': 7.4.47 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b46b452fd1..884a4b5b7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -66,4 +66,4 @@ injectWorkspacePackages: true shamefullyHoist: false verifyDepsBeforeRun: install minimumReleaseAgeExclude: - - '@immich/ui@0.81.1' + - '@immich/ui@0.83.0' diff --git a/web/package.json b/web/package.json index 1990e55ca9..b38fa163c0 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.81.1", + "@immich/ui": "^0.83.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mdi/js": "^7.4.47", "@noble/hashes": "^2.2.0", diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index 5005b435c5..6538dc1a47 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -14,6 +14,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; + import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { getAssetActions } from '$lib/services/asset.service'; import { faceManager } from '$lib/stores/face.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; @@ -23,6 +24,7 @@ import type { OnUndoDelete } from '$lib/utils/actions'; import { navigateToAsset } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; + import { navigate } from '$lib/utils/navigation'; import { InvocationTracker } from '$lib/utils/invocationTracker'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { toTimelineAsset } from '$lib/utils/timeline-util'; @@ -149,6 +151,15 @@ } }; + const onAssetsUndoArchive = async (assets: TimelineAsset[]) => { + if (assets.length === 0) { + return; + } + const restoredAsset = assets[0]; + await assetViewerManager.setAssetId(restoredAsset.id); + await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); + }; + onMount(() => { syncAssetViewerOpenClass(true); const slideshowStateUnsubscribe = slideshowState.subscribe((value) => { @@ -475,7 +486,7 @@ - + map?.jumpTo({ center, zoom })); }); - const onAssetsDelete = async () => { + const onAssetsChanged = async () => { mapMarkers = await loadMapMarkers(); }; - + this.#updateAssets([toTimelineAsset(asset)]), + AssetUpdate: (asset: AssetResponseDto) => { + const timelineAsset = toTimelineAsset(asset); + if (this.#options.albumId || this.#options.personId) { + this.#updateAssets([timelineAsset]); + } else { + this.upsertAssets([timelineAsset]); + } + }, + AssetsUnarchive: (assets) => this.upsertAssets(assets), }), ); } diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index ced71d80b4..fdfc15e636 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -24,6 +24,7 @@ import { get } from 'svelte/store'; import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { downloadBlob, downloadRequest, withError } from '$lib/utils'; @@ -31,6 +32,7 @@ import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; +import { toTimelineAsset } from '$lib/utils/timeline-util'; import { handleError } from './handle-error'; export const tagAssets = async ({ @@ -400,7 +402,12 @@ export const toggleArchive = async (asset: AssetResponseDto) => { }); asset.isArchived = data.isArchived; - toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`)); + if (asset.isArchived) { + const timelineAsset = toTimelineAsset(asset); + showUndoArchiveToast($t('added_to_archive'), [timelineAsset]); + } else { + toastManager.primary($t('removed_from_archive')); + } } catch (error) { handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } })); } @@ -408,7 +415,45 @@ export const toggleArchive = async (asset: AssetResponseDto) => { return asset; }; -export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => { +const showUndoArchiveToast = (description: string, assets: TimelineAsset[]) => { + const $t = get(t); + toastManager.primary({ + description, + button: (close) => ({ + label: $t('undo'), + onclick: () => { + close(); + void undoArchiveAssets(assets); + }, + }), + }); +}; + +const undoArchiveAssets = async (assets: TimelineAsset[]) => { + const $t = get(t); + try { + const ids = assets.map((a) => a.id); + if (ids.length > 0) { + await updateAssets({ + assetBulkUpdateDto: { + ids, + visibility: AssetVisibility.Timeline, + }, + }); + } + + for (const asset of assets) { + asset.visibility = AssetVisibility.Timeline; + } + eventManager.emit('AssetsUnarchive', assets); + eventManager.emit('AssetsUndoArchive', assets); + toastManager.success($t('unarchived_count', { values: { count: assets.length } })); + } catch (error) { + handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: false } })); + } +}; + +export const archiveAssets = async (assets: TimelineAsset[], visibility: AssetVisibility) => { const ids = assets.map(({ id }) => id); const $t = get(t); @@ -419,11 +464,11 @@ export const archiveAssets = async (assets: { id: string }[], visibility: AssetV }); } - toastManager.primary( - visibility === AssetVisibility.Archive - ? $t('archived_count', { values: { count: ids.length } }) - : $t('unarchived_count', { values: { count: ids.length } }), - ); + if (visibility === AssetVisibility.Archive) { + showUndoArchiveToast($t('archived_count', { values: { count: ids.length } }), assets); + } else { + toastManager.primary($t('unarchived_count', { values: { count: ids.length } })); + } } catch (error) { handleError( error, diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 5e8082c536..a54a9a6e26 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -165,7 +165,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): const people = assetResponse.people?.map((person) => person.name) || []; const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime); - const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC'); + // Keep this consistent with the bucket loader (getTimes), which stores fileCreatedAt as UTC + // components. The timeline sorts assets within a day by fileCreatedAt, so a mismatched + // representation here would place re-inserted assets (e.g. undo archive) in the wrong spot. + const fileCreatedAt = fromISODateTimeUTCToObject(assetResponse.fileCreatedAt); const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt); return { diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 88716f9429..1ef13c8504 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -329,6 +329,7 @@ onPersonAssetDelete={handlePersonAssetDelete} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} + onAssetsUnarchive={updateAssetCount} />