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}
/>