diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
index 108714348f..32622a1547 100644
--- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
@@ -2,13 +2,14 @@
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { user } from '$lib/stores/user.store';
+ import { Link } from '@immich/ui';
{#snippet children({ message })}
- {message}
+ {message}
{/snippet}
diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte
index d2873eca70..ba9cf37bff 100644
--- a/web/src/lib/components/timeline/Timeline.svelte
+++ b/web/src/lib/components/timeline/Timeline.svelte
@@ -23,7 +23,7 @@
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
- import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
+ import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -49,6 +49,7 @@
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
+ albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
@@ -81,6 +82,7 @@
showArchiveIcon = false,
isShared = false,
album = null,
+ albumUsers = [],
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
@@ -702,6 +704,7 @@
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
+ {albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
diff --git a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
index d5b1d2ecf6..b731635355 100644
--- a/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
+++ b/web/src/lib/components/timeline/actions/TimelineKeyboardActions.svelte
@@ -80,10 +80,7 @@
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- });
+ timelineManager.update(ids, (asset) => (asset.visibility = visibility));
deselectAllAssets();
};
diff --git a/web/src/lib/managers/timeline-manager/day-group.svelte.ts b/web/src/lib/managers/timeline-manager/day-group.svelte.ts
index 934ca1d4ff..e21e54a6e5 100644
--- a/web/src/lib/managers/timeline-manager/day-group.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/day-group.svelte.ts
@@ -6,7 +6,7 @@ import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { SvelteSet } from 'svelte/reactivity';
import type { MonthGroup } from './month-group.svelte';
-import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
+import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup {
@@ -101,7 +101,7 @@ export class DayGroup {
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
}
- runAssetOperation(ids: Set
, operation: AssetOperation) {
+ runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
@@ -122,7 +122,8 @@ export class DayGroup {
const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime };
- let { remove } = operation(asset);
+ const callbackResult = callback(asset);
+ let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime;
diff --git a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts
deleted file mode 100644
index 4bc99c0315..0000000000
--- a/web/src/lib/managers/timeline-manager/internal/operations-support.svelte.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
-import { AssetOrder } from '@immich/sdk';
-
-import { SvelteSet } from 'svelte/reactivity';
-import { GroupInsertionCache } from '../group-insertion-cache.svelte';
-import { MonthGroup } from '../month-group.svelte';
-import type { TimelineManager } from '../timeline-manager.svelte';
-import type { AssetOperation, TimelineAsset } from '../types';
-import { updateGeometry } from './layout-support.svelte';
-import { getMonthGroupByDate } from './search-support.svelte';
-
-export function addAssetsToMonthGroups(
- timelineManager: TimelineManager,
- assets: TimelineAsset[],
- options: { order: AssetOrder },
-) {
- if (assets.length === 0) {
- return;
- }
-
- const addContext = new GroupInsertionCache();
- const updatedMonthGroups = new SvelteSet();
- const monthCount = timelineManager.months.length;
- for (const asset of assets) {
- let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
-
- if (!month) {
- month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
- month.isLoaded = true;
- timelineManager.months.push(month);
- }
-
- month.addTimelineAsset(asset, addContext);
- updatedMonthGroups.add(month);
- }
-
- if (timelineManager.months.length !== monthCount) {
- timelineManager.months.sort((a, b) => {
- return a.yearMonth.year === b.yearMonth.year
- ? b.yearMonth.month - a.yearMonth.month
- : b.yearMonth.year - a.yearMonth.year;
- });
- }
-
- for (const group of addContext.existingDayGroups) {
- group.sortAssets(options.order);
- }
-
- for (const monthGroup of addContext.bucketsWithNewDayGroups) {
- monthGroup.sortDayGroups();
- }
-
- for (const month of addContext.updatedBuckets) {
- month.sortDayGroups();
- updateGeometry(timelineManager, month, { invalidateHeight: true });
- }
- timelineManager.updateIntersections();
-}
-
-export function runAssetOperation(
- timelineManager: TimelineManager,
- ids: Set,
- operation: AssetOperation,
- options: { order: AssetOrder },
-) {
- if (ids.size === 0) {
- return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
- }
-
- const changedMonthGroups = new SvelteSet();
- let idsToProcess = new SvelteSet(ids);
- const idsProcessed = new SvelteSet();
- const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
- for (const month of timelineManager.months) {
- if (idsToProcess.size > 0) {
- const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
- if (moveAssets.length > 0) {
- combinedMoveAssets.push(moveAssets);
- }
- idsToProcess = setDifference(idsToProcess, processedIds);
- for (const id of processedIds) {
- idsProcessed.add(id);
- }
- if (changedGeometry) {
- changedMonthGroups.add(month);
- }
- }
- }
- if (combinedMoveAssets.length > 0) {
- addAssetsToMonthGroups(
- timelineManager,
- combinedMoveAssets.flat().map((a) => a.asset),
- options,
- );
- }
- const changedGeometry = changedMonthGroups.size > 0;
- for (const month of changedMonthGroups) {
- updateGeometry(timelineManager, month, { invalidateHeight: true });
- }
- if (changedGeometry) {
- timelineManager.updateIntersections();
- }
- return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
-}
diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts
index 1d9e1bbaa7..3926055cca 100644
--- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts
@@ -21,7 +21,7 @@ import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte';
import type { TimelineManager } from './timeline-manager.svelte';
-import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
+import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup {
@@ -50,12 +50,13 @@ export class MonthGroup {
readonly yearMonth: TimelineYearMonth;
constructor(
- store: TimelineManager,
+ timelineManager: TimelineManager,
yearMonth: TimelineYearMonth,
initialCount: number,
+ loaded: boolean,
order: AssetOrder = AssetOrder.Desc,
) {
- this.timelineManager = store;
+ this.timelineManager = timelineManager;
this.#initialCount = initialCount;
this.#sortOrder = order;
@@ -72,6 +73,9 @@ export class MonthGroup {
},
this.#handleLoadError,
);
+ if (loaded) {
+ this.isLoaded = true;
+ }
}
set intersecting(newValue: boolean) {
@@ -112,7 +116,7 @@ export class MonthGroup {
return this.dayGroups.sort((a, b) => b.day - a.day);
}
- runAssetOperation(ids: Set, operation: AssetOperation) {
+ runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
return {
moveAssets: [] as MoveAsset[],
@@ -130,7 +134,7 @@ export class MonthGroup {
while (index--) {
if (idsToProcess.size > 0) {
const group = dayGroups[index];
- const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
+ const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
index 62053f7a0d..bb58704214 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts
@@ -278,10 +278,11 @@ describe('TimelineManager', () => {
});
it('updates existing asset', () => {
+ const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
- timelineManager.upsertAssets([asset]);
+ expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -691,4 +692,42 @@ describe('TimelineManager', () => {
expect(discoveredAssets.size).toBe(assetCount);
});
});
+
+ describe('showAssetOwners', () => {
+ const LS_KEY = 'album-show-asset-owners';
+
+ beforeEach(() => {
+ // ensure clean state
+ globalThis.localStorage?.removeItem(LS_KEY);
+ });
+
+ it('defaults to false', () => {
+ const timelineManager = new TimelineManager();
+ expect(timelineManager.showAssetOwners).toBe(false);
+ });
+
+ it('setShowAssetOwners updates value', () => {
+ const timelineManager = new TimelineManager();
+ timelineManager.setShowAssetOwners(true);
+ expect(timelineManager.showAssetOwners).toBe(true);
+ timelineManager.setShowAssetOwners(false);
+ expect(timelineManager.showAssetOwners).toBe(false);
+ });
+
+ it('toggleShowAssetOwners flips value', () => {
+ const timelineManager = new TimelineManager();
+ expect(timelineManager.showAssetOwners).toBe(false);
+ timelineManager.toggleShowAssetOwners();
+ expect(timelineManager.showAssetOwners).toBe(true);
+ timelineManager.toggleShowAssetOwners();
+ expect(timelineManager.showAssetOwners).toBe(false);
+ });
+
+ it('persists across instances via localStorage', () => {
+ const a = new TimelineManager();
+ a.setShowAssetOwners(true);
+ const b = new TimelineManager();
+ expect(b.showAssetOwners).toBe(true);
+ });
+ });
});
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 e3327663b4..feba73a0f8 100644
--- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
+++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts
@@ -1,12 +1,9 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
+import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
-import {
- addAssetsToMonthGroups,
- runAssetOperation,
-} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import {
findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil,
@@ -17,17 +14,23 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
-import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
+import { PersistedLocalStorage } from '$lib/utils/persisted';
+import {
+ setDifference,
+ toTimelineAsset,
+ type TimelineDateTime,
+ type TimelineYearMonth,
+} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
-import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
+import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte';
import type {
AssetDescriptor,
- AssetOperation,
Direction,
+ MoveAsset,
ScrubberMonth,
TimelineAsset,
TimelineManagerOptions,
@@ -88,6 +91,19 @@ export class TimelineManager extends VirtualScrollManager {
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
+ #showAssetOwners = new PersistedLocalStorage('album-show-asset-owners', false);
+
+ get showAssetOwners() {
+ return this.#showAssetOwners.current;
+ }
+
+ setShowAssetOwners(value: boolean) {
+ this.#showAssetOwners.current = value;
+ }
+
+ toggleShowAssetOwners() {
+ this.#showAssetOwners.current = !this.#showAssetOwners.current;
+ }
constructor() {
super();
@@ -218,6 +234,7 @@ export class TimelineManager extends VirtualScrollManager {
this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count,
+ false,
this.#options.order,
);
});
@@ -323,7 +340,7 @@ export class TimelineManager extends VirtualScrollManager {
upsertAssets(assets: TimelineAsset[]) {
const notUpdated = this.#updateAssets(assets);
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
- addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc });
+ this.addAssetsUpsertSegments([...notExcluded]);
}
async findMonthGroupForAsset(id: string) {
@@ -400,38 +417,107 @@ export class TimelineManager extends VirtualScrollManager {
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
}
- updateAssetOperation(ids: string[], operation: AssetOperation) {
- runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
- }
-
- #updateAssets(assets: TimelineAsset[]) {
- const lookup = new SvelteMap(assets.map((asset) => [asset.id, asset]));
- const { unprocessedIds } = runAssetOperation(
- this,
- new SvelteSet(lookup.keys()),
- (asset) => {
- updateObject(asset, lookup.get(asset.id));
- return { remove: false };
- },
- { order: this.#options.order ?? AssetOrder.Desc },
- );
- const result: TimelineAsset[] = [];
- for (const id of unprocessedIds.values()) {
- result.push(lookup.get(id)!);
- }
- return result;
+ /**
+ * Executes callback on assets, handling moves between groups and removals due to filter criteria.
+ */
+ update(ids: string[], callback: (asset: TimelineAsset) => void) {
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ return this.#runAssetCallback(new Set(ids), callback);
}
removeAssets(ids: string[]) {
- const { unprocessedIds } = runAssetOperation(
- this,
- new SvelteSet(ids),
- () => {
- return { remove: true };
- },
- { order: this.#options.order ?? AssetOrder.Desc },
- );
- return [...unprocessedIds];
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ const result = this.#runAssetCallback(new Set(ids), () => ({ remove: true }));
+ return [...result.notUpdated];
+ }
+
+ protected upsertSegmentForAsset(asset: TimelineAsset) {
+ let month = getMonthGroupByDate(this, asset.localDateTime);
+
+ if (!month) {
+ month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
+ this.months.push(month);
+ }
+ return month;
+ }
+
+ /**
+ * Adds assets to existing segments, creating new segments as needed.
+ *
+ * This is an internal method that assumes the provided assets are not already
+ * present in the timeline. For updating existing assets, use updateAssetOperation().
+ */
+ protected addAssetsUpsertSegments(assets: TimelineAsset[]) {
+ if (assets.length === 0) {
+ return;
+ }
+ const context = new GroupInsertionCache();
+ const monthCount = this.months.length;
+ for (const asset of assets) {
+ this.upsertSegmentForAsset(asset).addTimelineAsset(asset, context);
+ }
+ if (this.months.length !== monthCount) {
+ this.postCreateSegments();
+ }
+ this.postUpsert(context);
+ }
+
+ #updateAssets(assets: TimelineAsset[]) {
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ const cache = new Map(assets.map((asset) => [asset.id, asset]));
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ const idsToUpdate = new Set(cache.keys());
+ const result = this.#runAssetCallback(idsToUpdate, (asset) => void updateObject(asset, cache.get(asset.id)));
+ const notUpdated: TimelineAsset[] = [];
+ for (const assetId of result.notUpdated) {
+ notUpdated.push(cache.get(assetId)!);
+ }
+ return notUpdated;
+ }
+
+ #runAssetCallback(ids: Set, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
+ if (ids.size === 0) {
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ return { updated: new Set(), notUpdated: ids, changedGeometry: false };
+ }
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ const changedMonthGroups = new Set();
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ let notUpdated = new Set(ids);
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
+ const updated = new Set();
+ const assetsToMoveSegments: MoveAsset[][] = [];
+ for (const month of this.months) {
+ if (notUpdated.size === 0) {
+ break;
+ }
+ const result = month.runAssetCallback(notUpdated, callback);
+ if (result.moveAssets.length > 0) {
+ assetsToMoveSegments.push(result.moveAssets);
+ }
+ if (result.changedGeometry) {
+ changedMonthGroups.add(month);
+ }
+ notUpdated = setDifference(notUpdated, result.processedIds);
+ for (const id of result.processedIds) {
+ updated.add(id);
+ }
+ }
+ const assetsToAdd = [];
+ for (const segment of assetsToMoveSegments) {
+ for (const moveAsset of segment) {
+ assetsToAdd.push(moveAsset.asset);
+ }
+ }
+ this.addAssetsUpsertSegments(assetsToAdd);
+ const changedGeometry = changedMonthGroups.size > 0;
+ for (const month of changedMonthGroups) {
+ updateGeometry(this, month, { invalidateHeight: true });
+ }
+ if (changedGeometry) {
+ this.updateIntersections();
+ }
+ return { updated, notUpdated, changedGeometry };
}
override refreshLayout() {
@@ -493,4 +579,28 @@ export class TimelineManager extends VirtualScrollManager {
getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc;
}
+
+ protected postCreateSegments(): void {
+ this.months.sort((a, b) => {
+ return a.yearMonth.year === b.yearMonth.year
+ ? b.yearMonth.month - a.yearMonth.month
+ : b.yearMonth.year - a.yearMonth.year;
+ });
+ }
+
+ protected postUpsert(context: GroupInsertionCache): void {
+ for (const group of context.existingDayGroups) {
+ group.sortAssets(this.#options.order);
+ }
+
+ for (const monthGroup of context.bucketsWithNewDayGroups) {
+ monthGroup.sortDayGroups();
+ }
+
+ for (const month of context.updatedBuckets) {
+ month.sortDayGroups();
+ updateGeometry(this, month, { invalidateHeight: true });
+ }
+ this.updateIntersections();
+ }
}
diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts
index 27c27dcb63..35d7178f97 100644
--- a/web/src/lib/managers/timeline-manager/types.ts
+++ b/web/src/lib/managers/timeline-manager/types.ts
@@ -37,8 +37,6 @@ export type TimelineAsset = {
longitude?: number | null;
};
-export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
-
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
export interface Viewport {
diff --git a/web/src/lib/modals/TagCreateModal.svelte b/web/src/lib/modals/TagCreateModal.svelte
index 360681d7b9..5c48e83b08 100644
--- a/web/src/lib/modals/TagCreateModal.svelte
+++ b/web/src/lib/modals/TagCreateModal.svelte
@@ -14,7 +14,7 @@
const { onClose, baseTag }: Props = $props();
- let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : '');
+ let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : '');
const createTag = async () => {
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });
diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts
index 2eb081a490..05de75d3bc 100644
--- a/web/src/lib/utils/actions.ts
+++ b/web/src/lib/utils/actions.ts
@@ -79,14 +79,15 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) {
- timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => {
- asset.stack = {
- id: stack.id,
- primaryAssetId: stack.primaryAssetId,
- assetCount: stack.assets.length,
- };
- return { remove: false };
- });
+ timelineManager.update(
+ [stack.primaryAssetId],
+ (asset) =>
+ (asset.stack = {
+ id: stack.id,
+ primaryAssetId: stack.primaryAssetId,
+ assetCount: stack.assets.length,
+ }),
+ );
timelineManager.removeAssets(toDeleteIds);
}
@@ -101,7 +102,7 @@ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, {
* @param assets - The array of asset response DTOs to update in the timeline manager.
*/
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
- timelineManager.updateAssetOperation(
+ timelineManager.update(
assets.map((asset) => asset.id),
(asset) => {
asset.stack = null;
diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts
index 267bb2eec7..64b51158c4 100644
--- a/web/src/lib/utils/tree-utils.ts
+++ b/web/src/lib/utils/tree-utils.ts
@@ -62,8 +62,16 @@ export class TreeNode extends Map {
const child = this.values().next().value!;
child.value = joinPaths(this.value, child.value);
child.parent = this.parent;
- this.parent.delete(this.value);
- this.parent.set(child.value, child);
+
+ const entries = Array.from(this.parent.entries());
+ this.parent.clear();
+ for (const [key, value] of entries) {
+ if (key === this.value) {
+ this.parent.set(child.value, child);
+ } else {
+ this.parent.set(key, value);
+ }
+ }
}
for (const child of this.values()) {
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index b99260eee4..3f4d3dd39f 100644
--- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -66,6 +66,8 @@
} from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
+ mdiAccountEye,
+ mdiAccountEyeOutline,
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
@@ -101,6 +103,9 @@
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
+ let timelineManager = $state() as TimelineManager;
+ let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
+
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -290,13 +295,17 @@
let album = $derived(data.album);
let albumId = $derived(album.id);
+ const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
+ const albumUsers = $derived(
+ showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
+ );
+
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
}
});
- let timelineManager = $state() as TimelineManager;
const options = $derived.by(() => {
if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
return {
@@ -418,6 +427,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.isFavorite = isFavorite;
- return { remove: false };
- })}
+ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
>
{/if}
@@ -570,11 +576,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- })}
+ onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{/if}
@@ -657,6 +659,13 @@
color="secondary"
offset={{ x: 175, y: 25 }}
>
+ {#if containsEditors}
+ timelineManager.toggleShowAssetOwners()}
+ />
+ {/if}
{#if album.assetCount > 0}
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- })}
+ onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
@@ -80,11 +76,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.isFavorite = isFavorite;
- return { remove: false };
- })}
+ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 7cb3bf8e17..781dc80ec8 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -85,11 +85,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- })}
+ onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
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 5dabd58e76..c822855310 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
@@ -492,11 +492,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.isFavorite = isFavorite;
- return { remove: false };
- })}
+ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
@@ -511,11 +507,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- })}
+ onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 669ea23921..8bf8dce94e 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -120,11 +120,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.isFavorite = isFavorite;
- return { remove: false };
- })}
+ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
>
@@ -148,11 +144,7 @@
- timelineManager.updateAssetOperation(ids, (asset) => {
- asset.visibility = visibility;
- return { remove: false };
- })}
+ onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}