diff --git a/i18n/en.json b/i18n/en.json
index 10d332f3bf..d6f31a65f0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1417,7 +1417,10 @@
"preview": "Preview",
"previous": "Previous",
"previous_memory": "Previous memory",
- "previous_or_next_photo": "Previous or next photo",
+ "previous_or_next_day": "Day forward/back",
+ "previous_or_next_month": "Month forward/back",
+ "previous_or_next_photo": "Photo forward/back",
+ "previous_or_next_year": "Year forward/back",
"primary": "Primary",
"privacy": "Privacy",
"profile": "Profile",
diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts
index 1d9340970d..21466780e8 100644
--- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts
+++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts
@@ -2,7 +2,7 @@ import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observe
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
-import { fireEvent, render } from '@testing-library/svelte';
+import { render } from '@testing-library/svelte';
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
@@ -45,34 +45,4 @@ describe('Thumbnail component', () => {
const tabbables = getTabbable(container!);
expect(tabbables.length).toBe(0);
});
-
- it('handleFocus should be called on focus of container', async () => {
- const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
- const handleFocusSpy = vi.fn();
- const { baseElement } = render(Thumbnail, {
- asset,
- handleFocus: handleFocusSpy,
- });
-
- const container = baseElement.querySelector('[data-thumbnail-focus-container]');
- expect(container).not.toBeNull();
- await fireEvent(container as HTMLElement, new FocusEvent('focus'));
-
- expect(handleFocusSpy).toBeCalled();
- });
-
- it('element will be focussed if not already', async () => {
- const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
- const handleFocusSpy = vi.fn();
- const { baseElement } = render(Thumbnail, {
- asset,
- handleFocus: handleFocusSpy,
- });
-
- const container = baseElement.querySelector('[data-thumbnail-focus-container]');
- expect(container).not.toBeNull();
- await fireEvent(container as HTMLElement, new FocusEvent('focus'));
-
- expect(handleFocusSpy).toBeCalled();
- });
});
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index 5e71fc4678..1f9a29268f 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -20,7 +20,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
- import { focusNext } from '$lib/utils/focus-util';
+ import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte';
@@ -48,7 +48,6 @@
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
- handleFocus?: () => void;
}
let {
@@ -67,7 +66,6 @@
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
- handleFocus = undefined,
imageClass = '',
brokenAssetClass = '',
dimmed = false,
@@ -140,12 +138,14 @@
let startX: number = 0;
let startY: number = 0;
+
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false;
const start = (evt: PointerEvent) => {
startX = evt.clientX;
startY = evt.clientY;
didPress = false;
+ // 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => {
onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true });
@@ -193,14 +193,41 @@
onSelect?.($state.snapshot(asset)) }}
+ onkeydown={(evt) => {
+ if (evt.key === 'Enter') {
+ callClickHandlers();
+ }
+ if (evt.key === 'x') {
+ onSelect?.(asset);
+ }
+ if (document.activeElement === element && evt.key === 'Escape') {
+ moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, 'next');
+ }
+ }}
+ onclick={handleClick}
+ bind:this={element}
+ data-asset={asset.id}
+ data-thumbnail-focus-container
+ tabindex={0}
+ role="link"
>
+
+
{#if (!loaded || thumbError) && asset.thumbhash}
{/if}
-
onSelect?.($state.snapshot(asset)) }}
- onkeydown={(evt) => {
- if (evt.key === 'Enter') {
- callClickHandlers();
- }
- if (evt.key === 'x') {
- onSelect?.(asset);
- }
- if (document.activeElement === element && evt.key === 'Escape') {
- focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
- }
- }}
- onclick={handleClick}
- bind:this={element}
- onfocus={handleFocus}
- data-thumbnail-focus-container
- tabindex={0}
- role="link"
>
{/if}
-
-
{#if !authManager.key && asset.isFavorite}
@@ -372,7 +366,6 @@
class={['absolute p-2 focus:outline-none', { 'cursor-not-allowed': disabled }]}
role="checkbox"
tabindex={-1}
- onfocus={handleFocus}
aria-checked={selected}
{disabled}
>
@@ -389,3 +382,9 @@
{/if}
+
+
diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte
index d5fb77a24f..a93d2e7cb8 100644
--- a/web/src/lib/components/elements/date-input.svelte
+++ b/web/src/lib/components/elements/date-input.svelte
@@ -8,9 +8,11 @@
id?: string;
name?: string;
placeholder?: string;
+ autofocus?: boolean;
+ onkeydown?: (e: KeyboardEvent) => void;
}
- let { type, value = $bindable(), max = undefined, ...rest }: Props = $props();
+ let { type, value = $bindable(), max = undefined, onkeydown, ...rest }: Props = $props();
let fallbackMax = $derived(type === 'date' ? '9999-12-31' : '9999-12-31T23:59');
@@ -30,5 +32,6 @@
if (e.key === 'Enter') {
value = updatedValue;
}
+ onkeydown?.(e);
}}
/>
diff --git a/web/src/lib/components/photos-page/actions/focus-actions.ts b/web/src/lib/components/photos-page/actions/focus-actions.ts
new file mode 100644
index 0000000000..5085faa0a3
--- /dev/null
+++ b/web/src/lib/components/photos-page/actions/focus-actions.ts
@@ -0,0 +1,78 @@
+import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
+import { moveFocus } from '$lib/utils/focus-util';
+import { InvocationTracker } from '$lib/utils/invocationTracker';
+import { tick } from 'svelte';
+
+const tracker = new InvocationTracker();
+
+const getFocusedThumb = () => {
+ const current = document.activeElement as HTMLElement | undefined;
+ if (current && current.dataset.thumbnailFocusContainer !== undefined) {
+ return current;
+ }
+};
+
+export const focusNextAsset = () =>
+ moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
+
+export const focusPreviousAsset = () =>
+ moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
+
+const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
+
+export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
+ const scrolled = scrollToAsset(asset);
+ if (scrolled) {
+ const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
+ element?.focus();
+ }
+};
+
+export const setFocusTo = async (
+ scrollToAsset: (asset: TimelineAsset) => boolean,
+ store: AssetStore,
+ direction: 'earlier' | 'later',
+ interval: 'day' | 'month' | 'year' | 'asset',
+) => {
+ if (tracker.isActive()) {
+ // there are unfinished running invocations, so return early
+ return;
+ }
+ const thumb = getFocusedThumb();
+ if (!thumb) {
+ return direction === 'earlier' ? focusNextAsset() : focusPreviousAsset();
+ }
+
+ const invocation = tracker.startInvocation();
+ const id = thumb.dataset.asset;
+ if (!thumb || !id) {
+ invocation.endInvocation();
+ return;
+ }
+
+ const asset =
+ direction === 'earlier'
+ ? await store.getEarlierAsset({ id }, interval)
+ : await store.getLaterAsset({ id }, interval);
+
+ if (!invocation.isStillValid()) {
+ return;
+ }
+
+ if (!asset) {
+ invocation.endInvocation();
+ return;
+ }
+
+ const scrolled = scrollToAsset(asset);
+ if (scrolled) {
+ await tick();
+ if (!invocation.isStillValid()) {
+ return;
+ }
+ const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
+ element?.focus();
+ }
+
+ invocation.endInvocation();
+};
diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte
index f963221b35..49fac572e2 100644
--- a/web/src/lib/components/photos-page/asset-date-group.svelte
+++ b/web/src/lib/components/photos-page/asset-date-group.svelte
@@ -10,15 +10,13 @@
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
- import { getDateLocaleString } from '$lib/utils/timeline-util';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
- import { flip } from 'svelte/animate';
-
import { uploadAssetsStore } from '$lib/stores/upload';
+ import { flip } from 'svelte/animate';
let { isUploading } = uploadAssetsStore;
@@ -34,6 +32,7 @@
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
+ onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
@@ -47,6 +46,7 @@
onSelect,
onSelectAssets,
onSelectAssetCandidates,
+ onScrollCompensation,
}: Props = $props();
let isMouseOverGroup = $state(false);
@@ -84,7 +84,7 @@
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
- if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
+ if (assetStore.count == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
@@ -103,9 +103,16 @@
function filterIntersecting(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
+
+ $effect.root(() => {
+ if (assetStore.scrollCompensation.bucket === bucket) {
+ onScrollCompensation(assetStore.scrollCompensation);
+ assetStore.clearScrollCompensation();
+ }
+ });
-{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
+{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
{@const absoluteWidth = dateGroup.left}
@@ -146,7 +153,7 @@
{/if}
-
+
{dateGroup.groupTitle}
@@ -158,7 +165,7 @@
style:height={dateGroup.height + 'px'}
style:width={dateGroup.width + 'px'}
>
- {#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
+ {#each filterIntersecting(dateGroup.intersectingAssets) as intersectingAsset (intersectingAsset.id)}
{@const position = intersectingAsset.position!}
{@const asset = intersectingAsset.asset!}
diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte
index e98d71b831..d7a963102c 100644
--- a/web/src/lib/components/photos-page/asset-grid.svelte
+++ b/web/src/lib/components/photos-page/asset-grid.svelte
@@ -4,7 +4,12 @@
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
+ import {
+ setFocusToAsset as setFocusAssetInit,
+ setFocusTo as setFocusToInit,
+ } from '$lib/components/photos-page/actions/focus-actions';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
+ import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
import { AppRoute, AssetAction } from '$lib/constants';
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
@@ -27,10 +32,10 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
- import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation';
- import { type ScrubberListener } from '$lib/utils/timeline-util';
+ import { type ScrubberListener, type TimelinePlainYearMonth } from '$lib/utils/timeline-util';
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
+ import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte';
@@ -90,8 +95,9 @@
let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true);
+ let isShowSelectDate = $state(false);
let scrubBucketPercent = $state(0);
- let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
+ let scrubBucket: TimelinePlainYearMonth | undefined = $state();
let scrubOverallPercent: number = $state(0);
let scrubberWidth = $state(0);
@@ -116,42 +122,69 @@
});
const scrollTo = (top: number) => {
- element?.scrollTo({ top });
- showSkeleton = false;
+ if (element) {
+ element.scrollTo({ top });
+ }
+ };
+ const scrollTop = (top: number) => {
+ if (element) {
+ element.scrollTop = top;
+ }
+ };
+ const scrollBy = (y: number) => {
+ if (element) {
+ element.scrollBy(0, y);
+ }
};
-
const scrollToTop = () => {
scrollTo(0);
};
- const scrollToAsset = async (assetId: string) => {
- try {
- const bucket = await assetStore.findBucketForAsset(assetId);
- if (bucket) {
- const height = bucket.findAssetAbsolutePosition(assetId);
- if (height) {
- scrollTo(height);
- assetStore.updateIntersections();
- return true;
- }
- }
- } catch {
- // ignore errors - asset may not be in the store
+ const getAssetHeight = (assetId: string, bucket: AssetBucket) => {
+ // the following method may trigger any layouts, so need to
+ // handle any scroll compensation that may have been set
+ const height = bucket!.findAssetAbsolutePosition(assetId);
+
+ while (assetStore.scrollCompensation.bucket) {
+ handleScrollCompensation(assetStore.scrollCompensation);
+ assetStore.clearScrollCompensation();
}
- return false;
+ return height;
+ };
+
+ const scrollToAssetId = async (assetId: string) => {
+ const bucket = await assetStore.findBucketForAsset(assetId);
+ if (!bucket) {
+ return false;
+ }
+ const height = getAssetHeight(assetId, bucket);
+ scrollTo(height);
+ updateSlidingWindow();
+ return true;
+ };
+
+ const scrollToAsset = (asset: TimelineAsset) => {
+ const bucket = assetStore.getBucketIndexByAssetId(asset.id);
+ if (!bucket) {
+ return false;
+ }
+ const height = getAssetHeight(asset.id, bucket);
+ scrollTo(height);
+ updateSlidingWindow();
+ return true;
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
- scrolled = await scrollToAsset(scrollTarget);
+ scrolled = await scrollToAssetId(scrollTarget);
}
-
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollToTop();
}
+ showSkeleton = false;
};
beforeNavigate(() => (assetStore.suspendTransitions = true));
@@ -185,6 +218,7 @@
} else {
scrollToTop();
}
+ showSkeleton = false;
}, 500);
}
};
@@ -204,23 +238,28 @@
const updateIsScrolling = () => (assetStore.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
- const compensateScrollCallback = ({ delta, top }: { delta?: number; top?: number }) => {
- if (delta) {
- element?.scrollBy(0, delta);
- } else if (top) {
- element?.scrollTo({ top });
+
+ const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => {
+ if (heightDelta !== undefined) {
+ scrollBy(heightDelta);
+ } else if (scrollTop !== undefined) {
+ scrollTo(scrollTop);
}
+ // Yes, updateSlideWindow() is called by the onScroll event triggered as a result of
+ // the above calls. However, this delay is enough time to set the intersecting property
+ // of the bucket to false, then true, which causes the DOM nodes to be recreated,
+ // causing bad perf, and also, disrupting focus of those elements.
+ updateSlidingWindow();
};
+
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
onMount(() => {
- assetStore.setCompensateScrollCallback(compensateScrollCallback);
if (!enableRouting) {
showSkeleton = false;
}
const disposeHmr = hmrSupport();
return () => {
- assetStore.setCompensateScrollCallback();
disposeHmr();
};
});
@@ -241,16 +280,14 @@
const topOffset = bucket.top;
const maxScrollPercent = getMaxScrollPercent();
const delta = bucket.bucketHeight * bucketScrollPercent;
- const scrollTop = (topOffset + delta) * maxScrollPercent;
+ const scrollToTop = (topOffset + delta) * maxScrollPercent;
- if (element) {
- element.scrollTop = scrollTop;
- }
+ scrollTop(scrollToTop);
};
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const onScrub: ScrubberListener = (
- bucketDate: string | undefined,
+ bucketDate: { year: number; month: number } | undefined,
scrollPercent: number,
bucketScrollPercent: number,
) => {
@@ -258,12 +295,11 @@
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll();
const offset = maxScroll * scrollPercent;
- if (!element) {
- return;
- }
- element.scrollTop = offset;
+ scrollTop(offset);
} else {
- const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
+ const bucket = assetStore.buckets.find(
+ (bucket) => bucket.yearMonth.year === bucketDate.year && bucket.yearMonth.month === bucketDate.month,
+ );
if (!bucket) {
return;
}
@@ -303,7 +339,7 @@
const bucketsLength = assetStore.buckets.length;
for (let i = -1; i < bucketsLength + 1; i++) {
- let bucket: { bucketDate: string | undefined } | undefined;
+ let bucket: TimelinePlainYearMonth | undefined;
let bucketHeight = 0;
if (i === -1) {
// lead-in
@@ -312,7 +348,7 @@
// lead-out
bucketHeight = bottomSectionHeight;
} else {
- bucket = assetStore.buckets[i];
+ bucket = assetStore.buckets[i].yearMonth;
bucketHeight = assetStore.buckets[i].bucketHeight;
}
@@ -326,7 +362,7 @@
// compensate for lost precision/rounding errors advance to the next bucket, if present
if (scrubBucketPercent > 0.9999 && i + 1 < bucketsLength - 1) {
- scrubBucket = assetStore.buckets[i + 1];
+ scrubBucket = assetStore.buckets[i + 1].yearMonth;
scrubBucketPercent = 0;
}
@@ -385,12 +421,6 @@
deselectAllAssets();
};
- const focusElement = () => {
- if (document.activeElement === document.body) {
- element?.focus();
- }
- };
-
const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
@@ -398,37 +428,36 @@
};
const handlePrevious = async () => {
- const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
+ const laterAsset = await assetStore.getLaterAsset($viewingAsset);
- if (previousAsset) {
- const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
- const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
+ if (laterAsset) {
+ const preloadAsset = await assetStore.getLaterAsset(laterAsset);
+ const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
- await navigate({ targetRoute: 'current', assetId: previousAsset.id });
+ await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
- return !!previousAsset;
+ return !!laterAsset;
};
const handleNext = async () => {
- const nextAsset = await assetStore.getNextAsset($viewingAsset);
- if (nextAsset) {
- const preloadAsset = await assetStore.getNextAsset(nextAsset);
- const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
+ const earlierAsset = await assetStore.getEarlierAsset($viewingAsset);
+ if (earlierAsset) {
+ const preloadAsset = await assetStore.getEarlierAsset(earlierAsset);
+ const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
- await navigate({ targetRoute: 'current', assetId: nextAsset.id });
+ await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
- return !!nextAsset;
+ return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await assetStore.getRandomAsset();
if (randomAsset) {
- const preloadAsset = await assetStore.getNextAsset(randomAsset);
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
- assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
+ assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
@@ -514,7 +543,7 @@
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
- selectAssetCandidates(asset);
+ void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
@@ -532,7 +561,7 @@
}
}
- if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
+ if (assetStore.count == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
@@ -545,8 +574,8 @@
}
onSelect(asset);
- if (singleSelect && element) {
- element.scrollTop = 0;
+ if (singleSelect) {
+ scrollTop(0);
return;
}
@@ -583,8 +612,8 @@
break;
}
if (started) {
- await assetStore.loadBucket(bucket.bucketDate);
- for (const asset of bucket.getAssets()) {
+ await assetStore.loadBucket(bucket.yearMonth);
+ for (const asset of bucket.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
@@ -623,7 +652,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
- const selectAssetCandidates = (endAsset: TimelineAsset) => {
+ const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
@@ -633,16 +662,8 @@
return;
}
- const assets = assetsSnapshot(assetStore.getAssets());
-
- let start = assets.findIndex((a) => a.id === startAsset.id);
- let end = assets.findIndex((a) => a.id === endAsset.id);
-
- if (start > end) {
- [start, end] = [end, start];
- }
-
- assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
+ const assets = assetsSnapshot(await assetStore.retrieveRange(startAsset, endAsset));
+ assetInteraction.setAssetSelectionCandidates(assets);
};
const onSelectStart = (e: Event) => {
@@ -651,9 +672,6 @@
}
};
- const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
- const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
-
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
@@ -675,6 +693,9 @@
}
});
+ const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, assetStore);
+ const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
+
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
@@ -686,10 +707,15 @@
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteraction) },
- { shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
- { shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
- { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
- { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
+ { shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
+ { shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
+ { shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
+ { shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
+ { shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
+ { shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
+ { shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
+ { shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
+ { shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (assetInteraction.selectionActive) {
@@ -720,7 +746,7 @@
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
- selectAssetCandidates(lastAssetMouseEvent);
+ void selectAssetCandidates(lastAssetMouseEvent);
}
});
@@ -735,6 +761,22 @@
/>
{/if}
+{#if isShowSelectDate}
+ {
+ isShowSelectDate = false;
+ const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime).toObject());
+ if (asset) {
+ setFocusAsset(asset);
+ }
+ }}
+ onCancel={() => (isShowSelectDate = false)}
+ />
+{/if}
+
{#if assetStore.buckets.length > 0}
handleGroupSelect(assetStore, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
+ onScrollCompensation={handleScrollCompensation}
/>
{/if}
diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte
index 3dd3d2cd82..472e3b11a1 100644
--- a/web/src/lib/components/shared-components/change-date.svelte
+++ b/web/src/lib/components/shared-components/change-date.svelte
@@ -6,13 +6,22 @@
import Combobox, { type ComboBoxOption } from './combobox.svelte';
interface Props {
+ title?: string;
initialDate?: DateTime;
initialTimeZone?: string;
+ timezoneInput?: boolean;
onCancel: () => void;
onConfirm: (date: string) => void;
}
- let { initialDate = DateTime.now(), initialTimeZone = '', onCancel, onConfirm }: Props = $props();
+ let {
+ initialDate = DateTime.now(),
+ initialTimeZone = '',
+ title = $t('edit_date_and_time'),
+ timezoneInput = true,
+ onCancel,
+ onConfirm,
+ }: Props = $props();
type ZoneOption = {
/**
@@ -135,7 +144,7 @@
(confirmed ? handleConfirm() : onCancel())}
@@ -148,15 +157,17 @@
-
- handleOnSelect(option)}
- />
-
+ {#if timezoneInput}
+
+ handleOnSelect(option)}
+ />
+
+ {/if}
{/snippet}
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
index 6b0a25a84b..4e40ad5208 100644
--- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
+++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
@@ -14,7 +14,7 @@
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
- import { focusNext } from '$lib/utils/focus-util';
+ import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
@@ -271,8 +271,9 @@
}
};
- const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
- const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
+ const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
+ const focusPreviousAsset = () =>
+ moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
index 26a2e3f143..c18c7c83b0 100644
--- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte
+++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte
@@ -3,10 +3,9 @@
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util';
- import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
+ import { type ScrubberListener } from '$lib/utils/timeline-util';
import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
- import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
@@ -17,7 +16,7 @@
assetStore: AssetStore;
scrubOverallPercent?: number;
scrubBucketPercent?: number;
- scrubBucket?: { bucketDate: string | undefined };
+ scrubBucket?: { year: number; month: number };
leadout?: boolean;
scrubberWidth?: number;
onScrub?: ScrubberListener;
@@ -81,7 +80,7 @@
});
const toScrollFromBucketPercentage = (
- scrubBucket: { bucketDate: string | undefined } | undefined,
+ scrubBucket: { year: number; month: number } | undefined,
scrubBucketPercent: number,
scrubOverallPercent: number,
) => {
@@ -89,7 +88,7 @@
let offset = relativeTopOffset;
let match = false;
for (const segment of segments) {
- if (segment.bucketDate === scrubBucket.bucketDate) {
+ if (segment.month === scrubBucket.month && segment.year === scrubBucket.year) {
offset += scrubBucketPercent * segment.height;
match = true;
break;
@@ -120,8 +119,8 @@
count: number;
height: number;
dateFormatted: string;
- bucketDate: string;
- date: DateTime;
+ year: number;
+ month: number;
hasLabel: boolean;
hasDot: boolean;
};
@@ -141,9 +140,9 @@
top,
count: bucket.assetCount,
height: toScrollY(scrollBarPercentage),
- bucketDate: bucket.bucketDate,
- date: fromLocalDateTime(bucket.bucketDate),
dateFormatted: bucket.bucketDateFormattted,
+ year: bucket.year,
+ month: bucket.month,
hasLabel: false,
hasDot: false,
};
@@ -153,7 +152,7 @@
segment.hasLabel = true;
previousLabeledSegment = segment;
} else {
- if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
+ if (previousLabeledSegment?.year !== segment.year && height > MIN_YEAR_LABEL_DISTANCE) {
height = 0;
segment.hasLabel = true;
previousLabeledSegment = segment;
@@ -182,7 +181,13 @@
}
return activeSegment?.dataset.label;
});
- const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
+ const bucketDate = $derived.by(() => {
+ if (!activeSegment?.dataset.timeSegmentBucketDate) {
+ return undefined;
+ }
+ const [year, month] = activeSegment.dataset.timeSegmentBucketDate.split('-').map(Number);
+ return { year, month };
+ });
const scrollSegment = $derived.by(() => {
const y = scrollY;
let cur = relativeTopOffset;
@@ -289,12 +294,12 @@
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
- void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
- void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
+ void startScrub?.(bucketDate!, scrollPercent, bucketPercentY);
+ void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
}
if (wasDragging && !isDragging) {
- void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
+ void stopScrub?.(bucketDate!, scrollPercent, bucketPercentY);
return;
}
@@ -302,7 +307,7 @@
return;
}
- void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
+ void onScrub?.(bucketDate!, scrollPercent, bucketPercentY);
};
const getTouch = (event: TouchEvent) => {
if (event.touches.length === 1) {
@@ -404,7 +409,7 @@
}
if (next) {
event.preventDefault();
- void onScrub?.(next.bucketDate, -1, 0);
+ void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@@ -414,7 +419,7 @@
const next = segments[idx + 1];
if (next) {
event.preventDefault();
- void onScrub?.(next.bucketDate, -1, 0);
+ void onScrub?.({ year: next.year, month: next.month }, -1, 0);
return true;
}
}
@@ -517,7 +522,7 @@
class="relative"
style:height={relativeTopOffset + 'px'}
data-id="lead-in"
- data-time-segment-bucket-date={segments.at(0)?.date}
+ data-time-segment-bucket-date={segments.at(0)?.year + '-' + segments.at(0)?.month}
data-label={segments.at(0)?.dateFormatted}
>
{#if relativeTopOffset > 6}
@@ -525,18 +530,18 @@
{/if}
- {#each segments as segment (segment.date)}
+ {#each segments as segment (segment.year + '-' + segment.month)}
{#if !usingMobileDevice}
{#if segment.hasLabel}
- {segment.date.year}
+ {segment.year}
{/if}
{#if segment.hasDot}
diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte
index 2f16eaa817..dc355d42f1 100644
--- a/web/src/lib/modals/ShortcutsModal.svelte
+++ b/web/src/lib/modals/ShortcutsModal.svelte
@@ -25,6 +25,9 @@
shortcuts = {
general: [
{ key: ['←', '→'], action: $t('previous_or_next_photo') },
+ { key: ['D', 'd'], action: $t('previous_or_next_day') },
+ { key: ['M', 'm'], action: $t('previous_or_next_month') },
+ { key: ['Y', 'y'], action: $t('previous_or_next_year') },
{ key: ['x'], action: $t('select') },
{ key: ['Esc'], action: $t('back_close_deselect') },
{ key: ['Ctrl', 'k'], action: $t('search_your_photos') },
diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts
index 7864c9618b..c6e7686cdc 100644
--- a/web/src/lib/stores/assets-store.spec.ts
+++ b/web/src/lib/stores/assets-store.spec.ts
@@ -1,9 +1,18 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AbortError } from '$lib/utils';
+import { fromLocalDateTimeToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { AssetStore, type TimelineAsset } from './assets-store.svelte';
+async function getAssets(store: AssetStore) {
+ const assets = [];
+ for await (const asset of store.assetsIterator()) {
+ assets.push(asset);
+ }
+ return assets;
+}
+
describe('AssetStore', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -14,13 +23,13 @@ describe('AssetStore', () => {
const bucketAssets: Record
= {
'2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1)
- .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-02-01T00:00:00.000Z': timelineAssetFactory
.buildList(100)
- .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
- .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record = Object.fromEntries(
@@ -41,21 +50,21 @@ describe('AssetStore', () => {
it('should load buckets in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
-
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
it('calculates bucket height', () => {
const plainBuckets = assetStore.buckets.map((bucket) => ({
- bucketDate: bucket.bucketDate,
+ year: bucket.yearMonth.year,
+ month: bucket.yearMonth.month,
bucketHeight: bucket.bucketHeight,
}));
expect(plainBuckets).toEqual(
expect.arrayContaining([
- expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
- expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
- expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
+ expect.objectContaining({ year: 2024, month: 3, bucketHeight: 185.5 }),
+ expect.objectContaining({ year: 2024, month: 2, bucketHeight: 12_016 }),
+ expect.objectContaining({ year: 2024, month: 1, bucketHeight: 286 }),
]),
);
});
@@ -70,10 +79,10 @@ describe('AssetStore', () => {
const bucketAssets: Record = {
'2024-01-03T00:00:00.000Z': timelineAssetFactory
.buildList(1)
- .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
- .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@@ -95,47 +104,47 @@ describe('AssetStore', () => {
});
it('loads a bucket', async () => {
- expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
- await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
+ await assetStore.loadBucket({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
- expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid buckets', async () => {
- await assetStore.loadBucket('2023-01-01T00:00:00.000Z');
+ await assetStore.loadBucket({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
});
it('cancels bucket loading', async () => {
- const bucket = assetStore.getBucketByDate(2024, 1)!;
- void assetStore.loadBucket(bucket!.bucketDate);
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
+ void assetStore.loadBucket({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(bucket!.loader!.cancelToken!, 'abort');
bucket?.cancel();
expect(abortSpy).toBeCalledTimes(1);
- await assetStore.loadBucket(bucket!.bucketDate);
- expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(3);
+ await assetStore.loadBucket({ year: 2024, month: 1 });
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('prevents loading buckets multiple times', async () => {
await Promise.all([
- assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
- assetStore.loadBucket('2024-01-01T00:00:00.000Z'),
+ assetStore.loadBucket({ year: 2024, month: 1 }),
+ assetStore.loadBucket({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
- await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
+ await assetStore.loadBucket({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
});
it('allows loading a canceled bucket', async () => {
- const bucket = assetStore.getBucketByDate(2024, 1)!;
- const loadPromise = assetStore.loadBucket(bucket!.bucketDate);
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 })!;
+ const loadPromise = assetStore.loadBucket({ year: 2024, month: 1 });
bucket.cancel();
await loadPromise;
expect(bucket?.getAssets().length).toEqual(0);
- await assetStore.loadBucket(bucket.bucketDate);
+ await assetStore.loadBucket({ year: 2024, month: 1 });
expect(bucket!.getAssets().length).toEqual(3);
});
});
@@ -152,48 +161,50 @@ describe('AssetStore', () => {
it('is empty initially', () => {
expect(assetStore.buckets.length).toEqual(0);
- expect(assetStore.getAssets().length).toEqual(0);
+ expect(assetStore.count).toEqual(0);
});
it('adds assets to new bucket', () => {
const asset = timelineAssetFactory.build({
- localDateTime: '2024-01-20T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1);
- expect(assetStore.getAssets().length).toEqual(1);
+ expect(assetStore.count).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
- expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
- expect(assetStore.getAssets()[0].id).toEqual(asset.id);
+ expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
+ expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
+ expect(assetStore.buckets[0].getFirstAsset().id).toEqual(asset.id);
});
it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
- localDateTime: '2024-01-20T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]);
expect(assetStore.buckets.length).toEqual(1);
- expect(assetStore.getAssets().length).toEqual(2);
+ expect(assetStore.count).toEqual(2);
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
- expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
+ expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
+ expect(assetStore.buckets[0].yearMonth.month).toEqual(1);
});
it('orders assets in buckets by descending date', () => {
const assetOne = timelineAssetFactory.build({
- localDateTime: '2024-01-20T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
- localDateTime: '2024-01-15T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
});
const assetThree = timelineAssetFactory.build({
- localDateTime: '2024-01-16T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo, assetThree]);
- const bucket = assetStore.getBucketByDate(2024, 1);
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
expect(bucket).not.toBeNull();
expect(bucket?.getAssets().length).toEqual(3);
expect(bucket?.getAssets()[0].id).toEqual(assetOne.id);
@@ -202,15 +213,26 @@ describe('AssetStore', () => {
});
it('orders buckets by descending date', () => {
- const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
- const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
- const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
+ const assetOne = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
+ });
+ const assetTwo = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'),
+ });
+ const assetThree = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'),
+ });
assetStore.addAssets([assetOne, assetTwo, assetThree]);
expect(assetStore.buckets.length).toEqual(3);
- expect(assetStore.buckets[0].bucketDate).toEqual('2024-04-01T00:00:00.000Z');
- expect(assetStore.buckets[1].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
- expect(assetStore.buckets[2].bucketDate).toEqual('2023-01-01T00:00:00.000Z');
+ expect(assetStore.buckets[0].yearMonth.year).toEqual(2024);
+ expect(assetStore.buckets[0].yearMonth.month).toEqual(4);
+
+ expect(assetStore.buckets[1].yearMonth.year).toEqual(2024);
+ expect(assetStore.buckets[1].yearMonth.month).toEqual(1);
+
+ expect(assetStore.buckets[2].yearMonth.year).toEqual(2023);
+ expect(assetStore.buckets[2].yearMonth.month).toEqual(1);
});
it('updates existing asset', () => {
@@ -220,7 +242,7 @@ describe('AssetStore', () => {
assetStore.addAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
- expect(assetStore.getAssets().length).toEqual(1);
+ expect(assetStore.count).toEqual(1);
});
// disabled due to the wasm Justified Layout import
@@ -231,7 +253,7 @@ describe('AssetStore', () => {
const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true });
assetStore.addAssets([asset, trashedAsset]);
- expect(assetStore.getAssets()).toEqual([trashedAsset]);
+ expect(await getAssets(assetStore)).toEqual([trashedAsset]);
});
});
@@ -249,7 +271,7 @@ describe('AssetStore', () => {
assetStore.updateAssets([timelineAssetFactory.build()]);
expect(assetStore.buckets.length).toEqual(0);
- expect(assetStore.getAssets().length).toEqual(0);
+ expect(assetStore.count).toEqual(0);
});
it('updates an asset', () => {
@@ -257,29 +279,31 @@ describe('AssetStore', () => {
const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]);
- expect(assetStore.getAssets().length).toEqual(1);
- expect(assetStore.getAssets()[0].isFavorite).toEqual(false);
+ expect(assetStore.count).toEqual(1);
+ expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(false);
assetStore.updateAssets([updatedAsset]);
- expect(assetStore.getAssets().length).toEqual(1);
- expect(assetStore.getAssets()[0].isFavorite).toEqual(true);
+ expect(assetStore.count).toEqual(1);
+ expect(assetStore.buckets[0].getFirstAsset().isFavorite).toEqual(true);
});
it('asset moves buckets when asset date changes', () => {
- const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
- const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
+ const asset = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
+ });
+ const updatedAsset = { ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-20T12:00:00.000Z') };
assetStore.addAssets([asset]);
expect(assetStore.buckets.length).toEqual(1);
- expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
- expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(1);
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(1);
assetStore.updateAssets([updatedAsset]);
expect(assetStore.buckets.length).toEqual(2);
- expect(assetStore.getBucketByDate(2024, 1)).not.toBeUndefined();
- expect(assetStore.getBucketByDate(2024, 1)?.getAssets().length).toEqual(0);
- expect(assetStore.getBucketByDate(2024, 3)).not.toBeUndefined();
- expect(assetStore.getBucketByDate(2024, 3)?.getAssets().length).toEqual(1);
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })).not.toBeUndefined();
+ expect(assetStore.getBucketByDate({ year: 2024, month: 1 })?.getAssets().length).toEqual(0);
+ expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).not.toBeUndefined();
+ expect(assetStore.getBucketByDate({ year: 2024, month: 3 })?.getAssets().length).toEqual(1);
});
});
@@ -294,32 +318,36 @@ describe('AssetStore', () => {
});
it('ignores invalid IDs', () => {
- assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
+ assetStore.addAssets(
+ timelineAssetFactory.buildList(2, { localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z') }),
+ );
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
- expect(assetStore.getAssets().length).toEqual(2);
+ expect(assetStore.count).toEqual(2);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(2);
});
it('removes asset from bucket', () => {
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
- localDateTime: '2024-01-20T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]);
- expect(assetStore.getAssets().length).toEqual(1);
+ expect(assetStore.count).toEqual(1);
expect(assetStore.buckets.length).toEqual(1);
expect(assetStore.buckets[0].getAssets().length).toEqual(1);
});
it('does not remove bucket when empty', () => {
- const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
+ const assets = timelineAssetFactory.buildList(2, {
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
+ });
assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id));
- expect(assetStore.getAssets().length).toEqual(0);
+ expect(assetStore.count).toEqual(0);
expect(assetStore.buckets.length).toEqual(1);
});
});
@@ -339,28 +367,28 @@ describe('AssetStore', () => {
it('populated store returns first asset', () => {
const assetOne = timelineAssetFactory.build({
- localDateTime: '2024-01-20T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
});
const assetTwo = timelineAssetFactory.build({
- localDateTime: '2024-01-15T12:00:00.000Z',
+ localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
});
assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getFirstAsset()).toEqual(assetOne);
});
});
- describe('getPreviousAsset', () => {
+ describe('getLaterAsset', () => {
let assetStore: AssetStore;
const bucketAssets: Record = {
'2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1)
- .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
'2024-02-01T00:00:00.000Z': timelineAssetFactory
.buildList(6)
- .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
'2024-01-01T00:00:00.000Z': timelineAssetFactory
.buildList(3)
- .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
+ .map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
};
const bucketAssetsResponse: Record = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
@@ -378,58 +406,59 @@ describe('AssetStore', () => {
});
it('returns null for invalid assetId', async () => {
- expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
- expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
+ expect(() => assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
+ expect(await assetStore.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
});
it('returns previous assetId', async () => {
- await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
- const bucket = assetStore.getBucketByDate(2024, 1);
+ await assetStore.loadBucket({ year: 2024, month: 1 });
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
const a = bucket!.getAssets()[0];
const b = bucket!.getAssets()[1];
- const previous = await assetStore.getPreviousAsset(b);
+ const previous = await assetStore.getLaterAsset(b);
expect(previous).toEqual(a);
});
it('returns previous assetId spanning multiple buckets', async () => {
- await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
- await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
+ await assetStore.loadBucket({ year: 2024, month: 2 });
+ await assetStore.loadBucket({ year: 2024, month: 3 });
- const bucket = assetStore.getBucketByDate(2024, 2);
- const previousBucket = assetStore.getBucketByDate(2024, 3);
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
+ const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
const a = bucket!.getAssets()[0];
const b = previousBucket!.getAssets()[0];
- const previous = await assetStore.getPreviousAsset(a);
+ const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b);
});
it('loads previous bucket', async () => {
- await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
-
- const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
- const bucket = assetStore.getBucketByDate(2024, 2);
- const previousBucket = assetStore.getBucketByDate(2024, 3);
- const a = bucket!.getAssets()[0];
- const b = previousBucket!.getAssets()[0];
- const previous = await assetStore.getPreviousAsset(a);
+ await assetStore.loadBucket({ year: 2024, month: 2 });
+ const bucket = assetStore.getBucketByDate({ year: 2024, month: 2 });
+ const previousBucket = assetStore.getBucketByDate({ year: 2024, month: 3 });
+ const a = bucket!.getFirstAsset();
+ const b = previousBucket!.getFirstAsset();
+ const loadBucketSpy = vi.spyOn(bucket!.loader!, 'execute');
+ const previousBucketSpy = vi.spyOn(previousBucket!.loader!, 'execute');
+ const previous = await assetStore.getLaterAsset(a);
expect(previous).toEqual(b);
- expect(loadBucketSpy).toBeCalledTimes(1);
+ expect(loadBucketSpy).toBeCalledTimes(0);
+ expect(previousBucketSpy).toBeCalledTimes(0);
});
it('skips removed assets', async () => {
- await assetStore.loadBucket('2024-01-01T00:00:00.000Z');
- await assetStore.loadBucket('2024-02-01T00:00:00.000Z');
- await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
+ await assetStore.loadBucket({ year: 2024, month: 1 });
+ await assetStore.loadBucket({ year: 2024, month: 2 });
+ await assetStore.loadBucket({ year: 2024, month: 3 });
- const [assetOne, assetTwo, assetThree] = assetStore.getAssets();
+ const [assetOne, assetTwo, assetThree] = await getAssets(assetStore);
assetStore.removeAssets([assetTwo.id]);
- expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
+ expect(await assetStore.getLaterAsset(assetThree)).toEqual(assetOne);
});
it('returns null when no more assets', async () => {
- await assetStore.loadBucket('2024-03-01T00:00:00.000Z');
- expect(await assetStore.getPreviousAsset(assetStore.getAssets()[0])).toBeUndefined();
+ await assetStore.loadBucket({ year: 2024, month: 3 });
+ expect(await assetStore.getLaterAsset(assetStore.buckets[0].getFirstAsset())).toBeUndefined();
});
});
@@ -444,26 +473,37 @@ describe('AssetStore', () => {
});
it('returns null for invalid buckets', () => {
- expect(assetStore.getBucketByDate(-1, -1)).toBeUndefined();
- expect(assetStore.getBucketByDate(2024, 3)).toBeUndefined();
+ expect(assetStore.getBucketByDate({ year: -1, month: -1 })).toBeUndefined();
+ expect(assetStore.getBucketByDate({ year: 2024, month: 3 })).toBeUndefined();
});
it('returns the bucket index', () => {
- const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
- const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
+ const assetOne = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
+ });
+ const assetTwo = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
+ });
assetStore.addAssets([assetOne, assetTwo]);
- expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
- expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
+ expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
+ expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
+ expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
+ expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
it('ignores removed buckets', () => {
- const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
- const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
+ const assetOne = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
+ });
+ const assetTwo = timelineAssetFactory.build({
+ localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
+ });
assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]);
- expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.bucketDate).toEqual('2024-01-01T00:00:00.000Z');
+ expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
+ expect(assetStore.getBucketIndexByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
});
});
});
diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts
index ff06887900..36b4da573a 100644
--- a/web/src/lib/stores/assets-store.svelte.ts
+++ b/web/src/lib/stores/assets-store.svelte.ts
@@ -1,6 +1,5 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
-import { locale } from '$lib/stores/preferences.store';
import { CancellableTask } from '$lib/utils/cancellable-task';
import {
getJustifiedLayoutFromAssets,
@@ -8,7 +7,20 @@ import {
type CommonLayoutOptions,
type CommonPosition,
} from '$lib/utils/layout-utils';
-import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
+import {
+ formatBucketTitle,
+ formatGroupTitle,
+ fromLocalDateTimeToObject,
+ fromTimelinePlainDate,
+ fromTimelinePlainDateTime,
+ fromTimelinePlainYearMonth,
+ plainDateTimeCompare,
+ toISOLocalDateTime,
+ toTimelineAsset,
+ type TimelinePlainDate,
+ type TimelinePlainDateTime,
+ type TimelinePlainYearMonth,
+} from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import {
AssetOrder,
@@ -20,7 +32,6 @@ import {
type TimeBucketAssetResponseDto,
} from '@immich/sdk';
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
-import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import { get, writable, type Unsubscriber } from 'svelte/store';
@@ -36,6 +47,7 @@ export type AssetStoreOptions = Omit & {
timelineAlbumId?: string;
deferInit?: boolean;
};
+type AssetDescriptor = { id: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function updateObject(target: any, source: any): boolean {
@@ -48,7 +60,8 @@ function updateObject(target: any, source: any): boolean {
if (!source.hasOwnProperty(key)) {
continue;
}
- if (typeof target[key] === 'object') {
+ const isDate = target[key] instanceof Date;
+ if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
} else {
// Otherwise, directly copy the value
@@ -60,21 +73,17 @@ function updateObject(target: any, source: any): boolean {
}
return updated;
}
+type Direction = 'earlier' | 'later';
-export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
- return $state.snapshot(asset) as TimelineAsset;
-}
-
-export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
- return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
-}
+export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
+export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
export type TimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
- localDateTime: string;
+ localDateTime: TimelinePlainDateTime;
visibility: AssetVisibility;
isFavorite: boolean;
isTrashed: boolean;
@@ -99,8 +108,14 @@ class IntersectingAsset {
}
const store = this.#group.bucket.store;
- const topWindow = store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP;
- const bottomWindow = store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM;
+
+ const scrollCompensation = store.scrollCompensation;
+ const scrollCompensationHeightDelta = scrollCompensation?.heightDelta ?? 0;
+
+ const topWindow =
+ store.visibleWindow.top - store.headerHeight - INTERSECTION_EXPAND_TOP + scrollCompensationHeightDelta;
+ const bottomWindow =
+ store.visibleWindow.bottom + store.headerHeight + INTERSECTION_EXPAND_BOTTOM + scrollCompensationHeightDelta;
const positionTop = this.#group.absoluteDateGroupTop + this.position.top;
const positionBottom = positionTop + this.position.height;
@@ -123,19 +138,19 @@ class IntersectingAsset {
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
-type MoveAsset = { asset: TimelineAsset; year: number; month: number };
+type MoveAsset = { asset: TimelineAsset; date: TimelinePlainDate };
export class AssetDateGroup {
// --- public
readonly bucket: AssetBucket;
readonly index: number;
- readonly date: DateTime;
- readonly dayOfMonth: number;
- intersetingAssets: IntersectingAsset[] = $state([]);
+ readonly groupTitle: string;
+ readonly day: number;
+ intersectingAssets: IntersectingAsset[] = $state([]);
height = $state(0);
width = $state(0);
- intersecting = $derived.by(() => this.intersetingAssets.some((asset) => asset.intersecting));
+ intersecting = $derived.by(() => this.intersectingAssets.some((asset) => asset.intersecting));
// --- private
top: number = $state(0);
@@ -144,37 +159,44 @@ export class AssetDateGroup {
col = $state(0);
deferredLayout = false;
- constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) {
+ constructor(bucket: AssetBucket, index: number, day: number, groupTitle: string) {
this.index = index;
this.bucket = bucket;
- this.date = date;
- this.dayOfMonth = dayOfMonth;
+ this.day = day;
+ this.groupTitle = groupTitle;
}
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
- this.intersetingAssets.sort((a, b) => {
- const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC();
- const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC();
-
- if (sortOrder === AssetOrder.Asc) {
- return aDate.diff(bDate).milliseconds;
- }
-
- return bDate.diff(aDate).milliseconds;
- });
+ const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
+ this.intersectingAssets.sort((a, b) => sortFn(a.asset.localDateTime, b.asset.localDateTime));
}
getFirstAsset() {
- return this.intersetingAssets[0]?.asset;
+ return this.intersectingAssets[0]?.asset;
}
getRandomAsset() {
- const random = Math.floor(Math.random() * this.intersetingAssets.length);
- return this.intersetingAssets[random];
+ const random = Math.floor(Math.random() * this.intersectingAssets.length);
+ return this.intersectingAssets[random];
+ }
+
+ *assetsIterator(options: { startAsset?: TimelineAsset; direction?: Direction } = {}) {
+ const isEarlier = (options?.direction ?? 'earlier') === 'earlier';
+ let assetIndex = options?.startAsset
+ ? this.intersectingAssets.findIndex((intersectingAsset) => intersectingAsset.asset.id === options.startAsset!.id)
+ : isEarlier
+ ? 0
+ : this.intersectingAssets.length - 1;
+
+ while (assetIndex >= 0 && assetIndex < this.intersectingAssets.length) {
+ const intersectingAsset = this.intersectingAssets[assetIndex];
+ yield intersectingAsset.asset;
+ assetIndex += isEarlier ? 1 : -1;
+ }
}
getAssets() {
- return this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
+ return this.intersectingAssets.map((intersectingasset) => intersectingasset.asset);
}
runAssetOperation(ids: Set, operation: AssetOperation) {
@@ -191,27 +213,25 @@ export class AssetDateGroup {
const moveAssets: MoveAsset[] = [];
let changedGeometry = false;
for (const assetId of unprocessedIds) {
- const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId);
- if (index !== -1) {
- const asset = this.intersetingAssets[index].asset!;
- const oldTime = asset.localDateTime;
- let { remove } = operation(asset);
- const newTime = asset.localDateTime;
- if (oldTime !== newTime) {
- const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
- const year = utc.get('year');
- const month = utc.get('month');
- if (this.bucket.year !== year || this.bucket.month !== month) {
- remove = true;
- moveAssets.push({ asset, year, month });
- }
- }
- unprocessedIds.delete(assetId);
- processedIds.add(assetId);
- if (remove || this.bucket.store.isExcluded(asset)) {
- this.intersetingAssets.splice(index, 1);
- changedGeometry = true;
- }
+ const index = this.intersectingAssets.findIndex((ia) => ia.id == assetId);
+ if (index === -1) {
+ continue;
+ }
+
+ const asset = this.intersectingAssets[index].asset!;
+ const oldTime = { ...asset.localDateTime };
+ let { remove } = operation(asset);
+ const newTime = asset.localDateTime;
+ if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
+ const { year, month, day } = newTime;
+ remove = true;
+ moveAssets.push({ asset, date: { year, month, day } });
+ }
+ unprocessedIds.delete(assetId);
+ processedIds.add(assetId);
+ if (remove || this.bucket.store.isExcluded(asset)) {
+ this.intersectingAssets.splice(index, 1);
+ changedGeometry = true;
}
}
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
@@ -222,23 +242,19 @@ export class AssetDateGroup {
this.deferredLayout = true;
return;
}
- const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
+ const assets = this.intersectingAssets.map((intersetingAsset) => intersetingAsset.asset!);
const geometry = getJustifiedLayoutFromAssets(assets, options);
this.width = geometry.containerWidth;
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
- for (let i = 0; i < this.intersetingAssets.length; i++) {
+ for (let i = 0; i < this.intersectingAssets.length; i++) {
const position = getPosition(geometry, i);
- this.intersetingAssets[i].position = position;
+ this.intersectingAssets[i].position = position;
}
}
get absoluteDateGroupTop() {
return this.bucket.top + this.top;
}
-
- get groupTitle() {
- return formatDateGroupTitle(this.date);
- }
}
export interface Viewport {
@@ -259,11 +275,11 @@ class AddContext {
changedDateGroups = new Set();
newDateGroups = new Set();
- getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined {
+ getDateGroup({ year, month, day }: TimelinePlainDate): AssetDateGroup | undefined {
return this.lookupCache[year]?.[month]?.[day];
}
- setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) {
+ setDateGroup(dateGroup: AssetDateGroup, { year, month, day }: TimelinePlainDate) {
if (!this.lookupCache[year]) {
this.lookupCache[year] = {};
}
@@ -331,33 +347,27 @@ export class AssetBucket {
bucketCount: number = $derived(
this.isLoaded
- ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersetingAssets.length, 0)
+ ? this.dateGroups.reduce((accumulator, g) => accumulator + g.intersectingAssets.length, 0)
: this.#initialCount,
);
loader: CancellableTask | undefined;
isBucketHeightActual: boolean = $state(false);
readonly bucketDateFormatted: string;
- readonly bucketDate: string;
- readonly month: number;
- readonly year: number;
+ readonly yearMonth: TimelinePlainYearMonth;
- constructor(store: AssetStore, utcDate: DateTime, initialCount: number, order: AssetOrder = AssetOrder.Desc) {
+ constructor(
+ store: AssetStore,
+ yearMonth: TimelinePlainYearMonth,
+ initialCount: number,
+ order: AssetOrder = AssetOrder.Desc,
+ ) {
this.store = store;
this.#initialCount = initialCount;
this.#sortOrder = order;
- const year = utcDate.get('year');
- const month = utcDate.get('month');
- const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), {
- month: 'short',
- year: 'numeric',
- timeZone: 'UTC',
- });
- this.bucketDate = utcDate.toISO()!.toString();
- this.bucketDateFormatted = bucketDateFormatted;
- this.month = month;
- this.year = year;
+ this.yearMonth = yearMonth;
+ this.bucketDateFormatted = formatBucketTitle(fromTimelinePlainYearMonth(yearMonth));
this.loader = new CancellableTask(
() => {
@@ -373,13 +383,14 @@ export class AssetBucket {
set intersecting(newValue: boolean) {
const old = this.#intersecting;
- if (old !== newValue) {
- this.#intersecting = newValue;
- if (newValue) {
- void this.store.loadBucket(this.bucketDate);
- } else {
- this.cancel();
- }
+ if (old === newValue) {
+ return;
+ }
+ this.#intersecting = newValue;
+ if (newValue) {
+ void this.store.loadBucket(this.yearMonth);
+ } else {
+ this.cancel();
}
}
@@ -403,22 +414,12 @@ export class AssetBucket {
);
}
- containsAssetId(id: string) {
- for (const group of this.dateGroups) {
- const index = group.intersetingAssets.findIndex((a) => a.id == id);
- if (index !== -1) {
- return true;
- }
- }
- return false;
- }
-
sortDateGroups() {
if (this.#sortOrder === AssetOrder.Asc) {
- return this.dateGroups.sort((a, b) => a.date.diff(b.date).milliseconds);
+ return this.dateGroups.sort((a, b) => a.day - b.day);
}
- return this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds);
+ return this.dateGroups.sort((a, b) => b.day - a.day);
}
runAssetOperation(ids: Set, operation: AssetOperation) {
@@ -448,7 +449,7 @@ export class AssetBucket {
idsProcessed.add(id);
}
combinedChangedGeometry = combinedChangedGeometry || changedGeometry;
- if (group.intersetingAssets.length === 0) {
+ if (group.intersectingAssets.length === 0) {
dateGroups.splice(index, 1);
combinedChangedGeometry = true;
}
@@ -477,7 +478,7 @@ export class AssetBucket {
isTrashed: bucketAssets.isTrashed[i],
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
- localDateTime: bucketAssets.localDateTime[i],
+ localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]),
ownerId: bucketAssets.ownerId[i],
people,
projectionType: bucketAssets.projectionType[i],
@@ -509,29 +510,26 @@ export class AssetBucket {
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const { localDateTime } = timelineAsset;
- const date = DateTime.fromISO(localDateTime).toUTC();
- const month = date.get('month');
- const year = date.get('year');
- if (this.month !== month || this.year !== year) {
+ const { year, month } = this.yearMonth;
+ if (month !== localDateTime.month || year !== localDateTime.year) {
addContext.unprocessedAssets.push(timelineAsset);
return;
}
- const day = date.get('day');
- let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day);
-
+ let dateGroup = addContext.getDateGroup(localDateTime) || this.findDateGroupByDay(localDateTime.day);
if (dateGroup) {
- addContext.setDateGroup(dateGroup, year, month, day);
+ addContext.setDateGroup(dateGroup, localDateTime);
} else {
- dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
+ const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
+ dateGroup = new AssetDateGroup(this, this.dateGroups.length, localDateTime.day, groupTitle);
this.dateGroups.push(dateGroup);
- addContext.setDateGroup(dateGroup, year, month, day);
+ addContext.setDateGroup(dateGroup, localDateTime);
addContext.newDateGroups.add(dateGroup);
}
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
- dateGroup.intersetingAssets.push(intersectingAsset);
+ dateGroup.intersectingAssets.push(intersectingAsset);
addContext.changedDateGroups.add(dateGroup);
}
@@ -546,10 +544,14 @@ export class AssetBucket {
/** The svelte key for this view model object */
get viewId() {
- return this.bucketDate;
+ const { year, month } = this.yearMonth;
+ return year + '-' + month;
}
set bucketHeight(height: number) {
+ if (this.#bucketHeight === height) {
+ return;
+ }
const { store, percent } = this;
const index = store.buckets.indexOf(this);
const bucketHeightDelta = height - this.#bucketHeight;
@@ -575,10 +577,18 @@ export class AssetBucket {
// size adjustment
if (currentIndex > 0) {
if (index < currentIndex) {
- store.compensateScrollCallback?.({ delta: bucketHeightDelta });
- } else if (currentIndex == currentIndex && percent > 0) {
+ store.scrollCompensation = {
+ heightDelta: bucketHeightDelta,
+ scrollTop: undefined,
+ bucket: this,
+ };
+ } else if (percent > 0) {
const top = this.top + height * percent;
- store.compensateScrollCallback?.({ top });
+ store.scrollCompensation = {
+ heightDelta: undefined,
+ scrollTop: top,
+ bucket: this,
+ };
}
}
}
@@ -597,22 +607,70 @@ export class AssetBucket {
handleError(error, _$t('errors.failed_to_load_assets'));
}
- findDateGroupByDay(dayOfMonth: number) {
- return this.dateGroups.find((group) => group.dayOfMonth === dayOfMonth);
+ findDateGroupForAsset(asset: TimelineAsset) {
+ for (const group of this.dateGroups) {
+ if (group.intersectingAssets.some((IntersectingAsset) => IntersectingAsset.id === asset.id)) {
+ return group;
+ }
+ }
+ }
+
+ findDateGroupByDay(day: number) {
+ return this.dateGroups.find((group) => group.day === day);
}
findAssetAbsolutePosition(assetId: string) {
this.store.clearDeferredLayout(this);
-
for (const group of this.dateGroups) {
- const intersectingAsset = group.intersetingAssets.find((asset) => asset.id === assetId);
+ const intersectingAsset = group.intersectingAssets.find((asset) => asset.id === assetId);
if (intersectingAsset) {
- return this.top + group.top + intersectingAsset.position!.top + this.store.headerHeight;
+ if (!intersectingAsset.position) {
+ console.warn('No position for asset');
+ break;
+ }
+ return this.top + group.top + intersectingAsset.position.top + this.store.headerHeight;
}
}
return -1;
}
+ *assetsIterator(options?: { startDateGroup?: AssetDateGroup; startAsset?: TimelineAsset; direction?: Direction }) {
+ const direction = options?.direction ?? 'earlier';
+ let { startAsset } = options ?? {};
+ const isEarlier = direction === 'earlier';
+ let groupIndex = options?.startDateGroup
+ ? this.dateGroups.indexOf(options.startDateGroup)
+ : isEarlier
+ ? 0
+ : this.dateGroups.length - 1;
+
+ while (groupIndex >= 0 && groupIndex < this.dateGroups.length) {
+ const group = this.dateGroups[groupIndex];
+ yield* group.assetsIterator({ startAsset, direction });
+ startAsset = undefined;
+ groupIndex += isEarlier ? 1 : -1;
+ }
+ }
+
+ findAssetById(assetDescriptor: AssetDescriptor) {
+ return this.assetsIterator().find((asset) => asset.id === assetDescriptor.id);
+ }
+
+ findClosest(target: TimelinePlainDateTime) {
+ const targetDate = fromTimelinePlainDateTime(target);
+ let closest = undefined;
+ let smallestDiff = Infinity;
+ for (const current of this.assetsIterator()) {
+ const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
+ const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
+ if (diff < smallestDiff) {
+ smallestDiff = diff;
+ closest = current;
+ }
+ }
+ return closest;
+ }
+
cancel() {
this.loader?.cancel();
}
@@ -651,7 +709,8 @@ type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | Update
export type LiteBucket = {
bucketHeight: number;
assetCount: number;
- bucketDate: string;
+ year: number;
+ month: number;
bucketDateFormattted: string;
};
@@ -660,7 +719,6 @@ type AssetStoreLayoutOptions = {
headerHeight?: number;
gap?: number;
};
-
interface UpdateGeometryOptions {
invalidateHeight: boolean;
noDefer?: boolean;
@@ -674,6 +732,7 @@ export class AssetStore {
timelineHeight = $derived(
this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0) + this.topSectionHeight,
);
+ count = $derived(this.buckets.reduce((accumulator, b) => accumulator + b.bucketCount, 0));
// todo - name this better
albumAssets: Set = new SvelteSet();
@@ -683,7 +742,6 @@ export class AssetStore {
scrubberTimelineHeight: number = $state(0);
// -- should be private, but used by AssetBucket
- compensateScrollCallback: (({ delta, top }: { delta?: number; top?: number }) => void) | undefined;
topIntersectingBucket: AssetBucket | undefined = $state();
visibleWindow = $derived.by(() => ({
@@ -724,6 +782,15 @@ export class AssetStore {
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
+ scrollCompensation: {
+ heightDelta: number | undefined;
+ scrollTop: number | undefined;
+ bucket: AssetBucket | undefined;
+ } = $state({
+ heightDelta: 0,
+ scrollTop: 0,
+ bucket: undefined,
+ });
constructor() {}
@@ -819,8 +886,34 @@ export class AssetStore {
return this.#viewportHeight;
}
- getAssets() {
- return this.buckets.flatMap((bucket) => bucket.getAssets());
+ async *assetsIterator(options?: {
+ startBucket?: AssetBucket;
+ startDateGroup?: AssetDateGroup;
+ startAsset?: TimelineAsset;
+ direction?: Direction;
+ }) {
+ const direction = options?.direction ?? 'earlier';
+ let { startDateGroup, startAsset } = options ?? {};
+ for (const bucket of this.bucketsIterator({ direction, startBucket: options?.startBucket })) {
+ await this.loadBucket(bucket.yearMonth, { cancelable: false });
+ yield* bucket.assetsIterator({ startDateGroup, startAsset, direction });
+ // after the first bucket, we won't find startDateGroup or startAsset, so clear them
+ startDateGroup = startAsset = undefined;
+ }
+ }
+
+ *bucketsIterator(options?: { direction?: Direction; startBucket?: AssetBucket }) {
+ const isEarlier = options?.direction === 'earlier';
+ let startIndex = options?.startBucket
+ ? this.buckets.indexOf(options.startBucket)
+ : isEarlier
+ ? 0
+ : this.buckets.length - 1;
+
+ while (startIndex >= 0 && startIndex < this.buckets.length) {
+ yield this.buckets[startIndex];
+ startIndex += isEarlier ? 1 : -1;
+ }
}
#addPendingChanges(...changes: PendingChange[]) {
@@ -882,18 +975,37 @@ export class AssetStore {
return batch;
}
- // todo: this should probably be a method isteat
#findBucketForAsset(id: string) {
for (const bucket of this.buckets) {
- if (bucket.containsAssetId(id)) {
+ const asset = bucket.findAssetById({ id });
+ if (asset) {
+ return { bucket, asset };
+ }
+ }
+ }
+
+ #findBucketForDate(targetYearMonth: TimelinePlainYearMonth) {
+ for (const bucket of this.buckets) {
+ const { year, month } = bucket.yearMonth;
+ if (month === targetYearMonth.month && year === targetYearMonth.year) {
return bucket;
}
}
}
updateSlidingWindow(scrollTop: number) {
- this.#scrollTop = scrollTop;
- this.updateIntersections();
+ if (this.#scrollTop !== scrollTop) {
+ this.#scrollTop = scrollTop;
+ this.updateIntersections();
+ }
+ }
+
+ clearScrollCompensation() {
+ this.scrollCompensation = {
+ heightDelta: undefined,
+ scrollTop: undefined,
+ bucket: undefined,
+ };
}
updateIntersections() {
@@ -903,11 +1015,11 @@ export class AssetStore {
let topIntersectingBucket = undefined;
for (const bucket of this.buckets) {
this.#updateIntersection(bucket);
- if (!topIntersectingBucket && bucket.actuallyIntersecting && bucket.isLoaded) {
+ if (!topIntersectingBucket && bucket.actuallyIntersecting) {
topIntersectingBucket = bucket;
}
}
- if (this.topIntersectingBucket !== topIntersectingBucket) {
+ if (topIntersectingBucket !== undefined && this.topIntersectingBucket !== topIntersectingBucket) {
this.topIntersectingBucket = topIntersectingBucket;
}
for (const bucket of this.buckets) {
@@ -977,10 +1089,6 @@ export class AssetStore {
this.#pendingChanges = [];
}, 2500);
- setCompensateScrollCallback(compensateScrollCallback?: ({ delta, top }: { delta?: number; top?: number }) => void) {
- this.compensateScrollCallback = compensateScrollCallback;
- }
-
async #initialiazeTimeBuckets() {
const timebuckets = await getTimeBuckets({
...this.#options,
@@ -988,8 +1096,13 @@ export class AssetStore {
});
this.buckets = timebuckets.map((bucket) => {
- const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC();
- return new AssetBucket(this, utcDate, bucket.count, this.#options.order);
+ const date = new Date(bucket.timeBucket);
+ return new AssetBucket(
+ this,
+ { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
+ bucket.count,
+ this.#options.order,
+ );
});
this.albumAssets.clear();
this.#updateViewportGeometry(false);
@@ -1023,6 +1136,7 @@ export class AssetStore {
await this.#initialiazeTimeBuckets();
}, true);
}
+
public destroy() {
this.disconnect();
this.isInitialized = false;
@@ -1073,7 +1187,8 @@ export class AssetStore {
#createScrubBuckets() {
this.scrubberBuckets = this.buckets.map((bucket) => ({
assetCount: bucket.bucketCount,
- bucketDate: bucket.bucketDate,
+ year: bucket.yearMonth.year,
+ month: bucket.yearMonth.month,
bucketDateFormattted: bucket.bucketDateFormatted,
bucketHeight: bucket.bucketHeight,
}));
@@ -1165,16 +1280,12 @@ export class AssetStore {
bucket.isBucketHeightActual = true;
}
- async loadBucket(bucketDate: string, options?: { cancelable: boolean }): Promise {
+ async loadBucket(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }): Promise {
let cancelable = true;
if (options) {
cancelable = options.cancelable;
}
-
- const date = DateTime.fromISO(bucketDate).toUTC();
- const year = date.get('year');
- const month = date.get('month');
- const bucket = this.getBucketByDate(year, month);
+ const bucket = this.getBucketByDate(yearMonth);
if (!bucket) {
return;
}
@@ -1189,12 +1300,13 @@ export class AssetStore {
// so no need to load the bucket, it already has assets
return;
}
+ const timeBucket = toISOLocalDateTime(bucket.yearMonth);
+ const key = authManager.key;
const bucketResponse = await getTimeBucket(
{
...this.#options,
- timeBucket: bucketDate,
-
- key: authManager.key,
+ timeBucket,
+ key,
},
{ signal },
);
@@ -1203,8 +1315,8 @@ export class AssetStore {
const albumAssets = await getTimeBucket(
{
albumId: this.#options.timelineAlbumId,
- timeBucket: bucketDate,
- key: authManager.key,
+ timeBucket,
+ key,
},
{ signal },
);
@@ -1212,10 +1324,15 @@ export class AssetStore {
this.albumAssets.add(id);
}
}
- const unprocessed = bucket.addAssets(bucketResponse);
- if (unprocessed.length > 0) {
+ const unprocessedAssets = bucket.addAssets(bucketResponse);
+ if (unprocessedAssets.length > 0) {
console.error(
- `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
+ `Warning: getTimeBucket API returning assets not in requested month: ${bucket.yearMonth.month}, ${JSON.stringify(
+ unprocessedAssets.map((unprocessed) => ({
+ id: unprocessed.id,
+ localDateTime: unprocessed.localDateTime,
+ })),
+ )}`,
);
}
this.#layoutBucket(bucket);
@@ -1249,13 +1366,11 @@ export class AssetStore {
const updatedBuckets = new Set();
const bucketCount = this.buckets.length;
for (const asset of assets) {
- const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
- const year = utc.get('year');
- const month = utc.get('month');
- let bucket = this.getBucketByDate(year, month);
+ let bucket = this.getBucketByDate(asset.localDateTime);
if (!bucket) {
- bucket = new AssetBucket(this, utc, 1, this.#options.order);
+ bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
+ bucket.isLoaded = true;
this.buckets.push(bucket);
}
@@ -1265,7 +1380,9 @@ export class AssetStore {
if (this.buckets.length !== bucketCount) {
this.buckets.sort((a, b) => {
- return a.year === b.year ? b.month - a.month : b.year - a.year;
+ return a.yearMonth.year === b.yearMonth.year
+ ? b.yearMonth.month - a.yearMonth.month
+ : b.yearMonth.year - a.yearMonth.year;
});
}
@@ -1284,54 +1401,44 @@ export class AssetStore {
this.updateIntersections();
}
- getBucketByDate(year: number, month: number): AssetBucket | undefined {
- return this.buckets.find((bucket) => bucket.year === year && bucket.month === month);
+ getBucketByDate(targetYearMonth: TimelinePlainYearMonth): AssetBucket | undefined {
+ return this.buckets.find(
+ (bucket) => bucket.yearMonth.year === targetYearMonth.year && bucket.yearMonth.month === targetYearMonth.month,
+ );
}
async findBucketForAsset(id: string) {
- await this.initTask.waitUntilCompletion();
- let bucket = this.#findBucketForAsset(id);
- if (!bucket) {
- const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
- if (!asset || this.isExcluded(asset)) {
- return;
- }
- bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
+ if (!this.isInitialized) {
+ await this.initTask.waitUntilCompletion();
}
-
- if (bucket && bucket?.containsAssetId(id)) {
+ let { bucket } = this.#findBucketForAsset(id) ?? {};
+ if (bucket) {
+ return bucket;
+ }
+ const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
+ if (!asset || this.isExcluded(asset)) {
+ return;
+ }
+ bucket = await this.#loadBucketAtTime(asset.localDateTime, { cancelable: false });
+ if (bucket?.findAssetById({ id })) {
return bucket;
}
}
- async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
- let date = DateTime.fromISO(localDateTime).toUTC();
- // Only support TimeBucketSize.Month
- date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
- const iso = date.toISO()!;
- const year = date.get('year');
- const month = date.get('month');
- await this.loadBucket(iso, options);
- return this.getBucketByDate(year, month);
- }
-
- async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) {
- const bucketInfo = this.#findBucketForAsset(asset.id);
- if (bucketInfo) {
- return bucketInfo;
- }
- await this.#loadBucketAtTime(asset.localDateTime, options);
- return this.#findBucketForAsset(asset.id);
+ async #loadBucketAtTime(yearMonth: TimelinePlainYearMonth, options?: { cancelable: boolean }) {
+ await this.loadBucket(yearMonth, options);
+ return this.getBucketByDate(yearMonth);
}
getBucketIndexByAssetId(assetId: string) {
- return this.#findBucketForAsset(assetId);
+ const bucketInfo = this.#findBucketForAsset(assetId);
+ return bucketInfo?.bucket;
}
async getRandomBucket() {
const random = Math.floor(Math.random() * this.buckets.length);
const bucket = this.buckets[random];
- await this.loadBucket(bucket.bucketDate, { cancelable: false });
+ await this.loadBucket(bucket.yearMonth, { cancelable: false });
return bucket;
}
@@ -1349,7 +1456,7 @@ export class AssetStore {
const changedBuckets = new Set();
let idsToProcess = new Set(ids);
const idsProcessed = new Set();
- const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
+ const combinedMoveAssets: { asset: TimelineAsset; date: TimelinePlainDate }[][] = [];
for (const bucket of this.buckets) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
@@ -1419,85 +1526,141 @@ export class AssetStore {
return this.buckets[0]?.getFirstAsset();
}
- async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise {
- let bucket = await this.#getBucketInfoForAsset(asset);
+ async getLaterAsset(
+ assetDescriptor: AssetDescriptor,
+ interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
+ ): Promise {
+ return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
+ }
+
+ async getEarlierAsset(
+ assetDescriptor: AssetDescriptor,
+ interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
+ ): Promise {
+ return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
+ }
+
+ async getClosestAssetToDate(dateTime: TimelinePlainDateTime) {
+ const bucket = this.#findBucketForDate(dateTime);
if (!bucket) {
return;
}
-
- // Find which date group contains this asset
- for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
- const group = bucket.dateGroups[groupIndex];
- const assetIndex = group.intersetingAssets.findIndex((ia) => ia.id === asset.id);
-
- if (assetIndex !== -1) {
- // If not the first asset in this group, return the previous one
- if (assetIndex > 0) {
- return group.intersetingAssets[assetIndex - 1].asset;
- }
-
- // If there are previous date groups in this bucket, check the previous one
- if (groupIndex > 0) {
- const prevGroup = bucket.dateGroups[groupIndex - 1];
- return prevGroup.intersetingAssets.at(-1)?.asset;
- }
-
- // Otherwise, we need to look in the previous bucket
- break;
- }
+ await this.loadBucket(dateTime, { cancelable: false });
+ const asset = bucket.findClosest(dateTime);
+ if (asset) {
+ return asset;
}
-
- let bucketIndex = this.buckets.indexOf(bucket) - 1;
- while (bucketIndex >= 0) {
- bucket = this.buckets[bucketIndex];
- if (!bucket) {
- return;
- }
- await this.loadBucket(bucket.bucketDate);
- const previous = bucket.lastDateGroup?.intersetingAssets.at(-1)?.asset;
- if (previous) {
- return previous;
- }
- bucketIndex--;
+ for await (const asset of this.assetsIterator({ startBucket: bucket })) {
+ return asset;
}
}
- async getNextAsset(asset: { id: string; localDateTime: string }): Promise {
- let bucket = await this.#getBucketInfoForAsset(asset);
- if (!bucket) {
- return;
+ async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
+ let { asset: startAsset, bucket: startBucket } = this.#findBucketForAsset(start.id) ?? {};
+ if (!startBucket || !startAsset) {
+ return [];
+ }
+ let { asset: endAsset, bucket: endBucket } = this.#findBucketForAsset(end.id) ?? {};
+ if (!endBucket || !endAsset) {
+ return [];
+ }
+ let direction: Direction = 'earlier';
+ if (plainDateTimeCompare(true, startAsset.localDateTime, endAsset.localDateTime) < 0) {
+ // swap startAsset, startBucket with endAsset, endBucket
+ [startAsset, endAsset] = [endAsset, startAsset];
+ [startBucket, endBucket] = [endBucket, startBucket];
+ direction = 'earlier';
}
- // Find which date group contains this asset
- for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
- const group = bucket.dateGroups[groupIndex];
- const assetIndex = group.intersetingAssets.findIndex((ia) => ia.id === asset.id);
-
- if (assetIndex !== -1) {
- // If not the last asset in this group, return the next one
- if (assetIndex < group.intersetingAssets.length - 1) {
- return group.intersetingAssets[assetIndex + 1].asset;
- }
-
- // If there are more date groups in this bucket, check the next one
- if (groupIndex < bucket.dateGroups.length - 1) {
- return bucket.dateGroups[groupIndex + 1].intersetingAssets[0]?.asset;
- }
-
- // Otherwise, we need to look in the next bucket
+ const range: TimelineAsset[] = [];
+ const startDateGroup = startBucket.findDateGroupForAsset(startAsset);
+ for await (const targetAsset of this.assetsIterator({
+ startBucket,
+ startDateGroup,
+ startAsset,
+ direction,
+ })) {
+ range.push(targetAsset);
+ if (targetAsset.id === endAsset.id) {
break;
}
}
+ return range;
+ }
- let bucketIndex = this.buckets.indexOf(bucket) + 1;
- while (bucketIndex < this.buckets.length) {
- bucket = this.buckets[bucketIndex];
- await this.loadBucket(bucket.bucketDate);
- const next = bucket.dateGroups[0]?.intersetingAssets[0]?.asset;
- if (next) {
- return next;
+ async #getAssetWithOffset(
+ assetDescriptor: AssetDescriptor,
+ interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
+ direction: Direction,
+ ): Promise {
+ const { asset, bucket } = this.#findBucketForAsset(assetDescriptor.id) ?? {};
+ if (!bucket || !asset) {
+ return;
+ }
+
+ switch (interval) {
+ case 'asset': {
+ return this.#getAssetByAssetOffset(asset, bucket, direction);
+ }
+ case 'day': {
+ return this.#getAssetByDayOffset(asset, bucket, direction);
+ }
+ case 'month': {
+ return this.#getAssetByMonthOffset(bucket, direction);
+ }
+ case 'year': {
+ return this.#getAssetByYearOffset(bucket, direction);
+ }
+ }
+ }
+
+ async #getAssetByAssetOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
+ const dateGroup = bucket.findDateGroupForAsset(asset);
+ for await (const targetAsset of this.assetsIterator({
+ startBucket: bucket,
+ startDateGroup: dateGroup,
+ startAsset: asset,
+ direction,
+ })) {
+ if (asset.id === targetAsset.id) {
+ continue;
+ }
+ return targetAsset;
+ }
+ }
+
+ async #getAssetByDayOffset(asset: TimelineAsset, bucket: AssetBucket, direction: Direction) {
+ const dateGroup = bucket.findDateGroupForAsset(asset);
+ for await (const targetAsset of this.assetsIterator({
+ startBucket: bucket,
+ startDateGroup: dateGroup,
+ startAsset: asset,
+ direction,
+ })) {
+ if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
+ return targetAsset;
+ }
+ }
+ }
+
+ // starting at bucket, go to the earlier/later bucket by month, returning the first asset in that bucket
+ async #getAssetByMonthOffset(bucket: AssetBucket, direction: Direction) {
+ for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
+ if (targetBucket.yearMonth.month !== bucket.yearMonth.month) {
+ for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
+ return targetAsset;
+ }
+ }
+ }
+ }
+
+ async #getAssetByYearOffset(bucket: AssetBucket, direction: Direction) {
+ for (const targetBucket of this.bucketsIterator({ startBucket: bucket, direction })) {
+ if (targetBucket.yearMonth.year !== bucket.yearMonth.year) {
+ for await (const targetAsset of this.assetsIterator({ startBucket: targetBucket, direction })) {
+ return targetAsset;
+ }
}
- bucketIndex++;
}
}
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts
index 3abd0a596c..1838cb8eb5 100644
--- a/web/src/lib/utils/asset-utils.ts
+++ b/web/src/lib/utils/asset-utils.ts
@@ -477,13 +477,13 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
try {
for (const bucket of assetStore.buckets) {
- await assetStore.loadBucket(bucket.bucketDate);
+ await assetStore.loadBucket(bucket.yearMonth);
if (!get(isSelectingAllAssets)) {
assetInteraction.clearMultiselect();
break; // Cancelled
}
- assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
+ assetInteraction.selectAssets(assetsSnapshot([...bucket.assetsIterator()]));
for (const dateGroup of bucket.dateGroups) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
diff --git a/web/src/lib/utils/focus-util.ts b/web/src/lib/utils/focus-util.ts
index c95ed3f31d..433864f490 100644
--- a/web/src/lib/utils/focus-util.ts
+++ b/web/src/lib/utils/focus-util.ts
@@ -12,28 +12,43 @@ export const setDefaultTabbleOptions = (options: TabbableOpts) => {
export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer });
-export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
- const focusElements = focusable(document.body, { includeContainer: true });
- const current = document.activeElement as HTMLElement;
- const index = focusElements.indexOf(current);
- if (index === -1) {
- for (const element of focusElements) {
- if (selector(element)) {
- element.focus();
- return;
- }
- }
- focusElements[0].focus();
+export const moveFocus = (
+ selector: (element: HTMLElement | SVGElement) => boolean,
+ direction: 'previous' | 'next',
+): void => {
+ const focusableElements = focusable(document.body, { includeContainer: true });
+
+ if (focusableElements.length === 0) {
return;
}
- const totalElements = focusElements.length;
- let i = index;
+
+ const currentElement = document.activeElement as HTMLElement | null;
+ const currentIndex = currentElement ? focusableElements.indexOf(currentElement) : -1;
+
+ // If no element is focused, focus the first matching element or the first focusable element
+ if (currentIndex === -1) {
+ const firstMatchingElement = focusableElements.find((element) => selector(element));
+ if (firstMatchingElement) {
+ firstMatchingElement.focus();
+ } else if (focusableElements[0]) {
+ focusableElements[0].focus();
+ }
+ return;
+ }
+
+ // Calculate the step direction
+ const step = direction === 'next' ? 1 : -1;
+ const totalElements = focusableElements.length;
+
+ // Search for the next focusable element that matches the selector
+ let nextIndex = currentIndex;
do {
- i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
- const next = focusElements[i];
- if (isTabbable(next) && selector(next)) {
- next.focus();
+ nextIndex = (nextIndex + step + totalElements) % totalElements;
+ const candidateElement = focusableElements[nextIndex];
+
+ if (isTabbable(candidateElement) && selector(candidateElement)) {
+ candidateElement.focus();
break;
}
- } while (i !== index);
+ } while (nextIndex !== currentIndex);
};
diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts
new file mode 100644
index 0000000000..ebc97dfde0
--- /dev/null
+++ b/web/src/lib/utils/invocationTracker.ts
@@ -0,0 +1,53 @@
+/**
+ * Tracks the state of asynchronous invocations to handle race conditions and stale operations.
+ * This class helps manage concurrent operations by tracking which invocations are active
+ * and allowing operations to check if they're still valid.
+ */
+export class InvocationTracker {
+ /** Counter for the number of invocations that have been started */
+ invocationsStarted = 0;
+ /** Counter for the number of invocations that have been completed */
+ invocationsEnded = 0;
+
+ constructor() {}
+
+ /**
+ * Starts a new invocation and returns an object with utilities to manage the invocation lifecycle.
+ * @returns An object containing methods to manage the invocation:
+ * - isInvalidInvocationError: Checks if an error is an invalid invocation error
+ * - checkStillValid: Throws an error if the invocation is no longer valid
+ * - endInvocation: Marks the invocation as complete
+ */
+ startInvocation() {
+ this.invocationsStarted++;
+ const invocation = this.invocationsStarted;
+
+ return {
+ /**
+ * Throws an error if this invocation is no longer valid
+ * @throws {Error} If the invocation is no longer valid
+ */
+ isStillValid: () => {
+ if (invocation !== this.invocationsStarted) {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Marks this invocation as complete
+ */
+ endInvocation: () => {
+ this.invocationsEnded = invocation;
+ },
+ };
+ }
+
+ /**
+ * Checks if there are any active invocations
+ * @returns True if there are active invocations, false otherwise
+ */
+ isActive() {
+ return this.invocationsStarted !== this.invocationsEnded;
+ }
+}
diff --git a/web/src/lib/utils/thumbnail-util.spec.ts b/web/src/lib/utils/thumbnail-util.spec.ts
index f3f9d51fad..65df82bee3 100644
--- a/web/src/lib/utils/thumbnail-util.spec.ts
+++ b/web/src/lib/utils/thumbnail-util.spec.ts
@@ -56,12 +56,21 @@ describe('getAltText', () => {
people?: Person[];
expected: string;
}) => {
+ const testDate = new Date('2024-01-01T12:00:00.000Z');
const asset: TimelineAsset = {
id: 'test-id',
ownerId: 'test-owner',
ratio: 1,
thumbhash: null,
- localDateTime: '2024-01-01T12:00:00.000Z',
+ localDateTime: {
+ year: testDate.getUTCFullYear(),
+ month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
+ day: testDate.getUTCDate(),
+ hour: testDate.getUTCHours(),
+ minute: testDate.getUTCMinutes(),
+ second: testDate.getUTCSeconds(),
+ millisecond: testDate.getUTCMilliseconds(),
+ },
visibility: AssetVisibility.Timeline,
isFavorite: false,
isTrashed: false,
diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts
index 954cfa3314..2b5982d510 100644
--- a/web/src/lib/utils/thumbnail-util.ts
+++ b/web/src/lib/utils/thumbnail-util.ts
@@ -1,8 +1,8 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
+import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
-import { fromLocalDateTime } from './timeline-util';
/**
* Calculate thumbnail size based on number of assets and viewport width
@@ -40,7 +40,10 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
export const getAltText = derived(t, ($t) => {
return (asset: TimelineAsset) => {
- const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
+ const date = fromTimelinePlainDateTime(asset.localDateTime).toJSDate().toLocaleString(get(locale), {
+ dateStyle: 'long',
+ timeZone: 'UTC',
+ });
const hasPlace = asset.city && asset.country;
const peopleCount = asset.people.length;
diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts
index fca68a6aec..d8252271a2 100644
--- a/web/src/lib/utils/timeline-util.ts
+++ b/web/src/lib/utils/timeline-util.ts
@@ -1,15 +1,12 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
-
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
-
-import { memoize } from 'lodash-es';
import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store';
export type ScrubberListener = (
- bucketDate: string | undefined,
+ bucketDate: { year: number; month: number },
overallScrollPercent: number,
bucketScrollPercent: number,
) => void | Promise;
@@ -17,8 +14,44 @@ export type ScrubberListener = (
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
+export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime =>
+ (fromLocalDateTime(localDateTime) as DateTime).toObject();
+
+export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime =>
+ DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime;
+
+export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime =>
+ DateTime.fromObject(
+ { year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day },
+ { zone: 'local', locale: get(locale) },
+ ) as DateTime;
+
+export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime =>
+ DateTime.fromObject(
+ { year: timelineYearMonth.year, month: timelineYearMonth.month },
+ { zone: 'local', locale: get(locale) },
+ ) as DateTime;
+
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
- DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
+ DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) });
+
+export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string =>
+ (fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime).toISO();
+
+export function formatBucketTitle(_date: DateTime): string {
+ if (!_date.isValid) {
+ return _date.toString();
+ }
+ const date = _date as DateTime;
+ return date.toLocaleString(
+ {
+ month: 'short',
+ year: 'numeric',
+ timeZone: 'UTC',
+ },
+ { locale: get(locale) },
+ );
+}
export function formatGroupTitle(_date: DateTime): string {
if (!_date.isValid) {
@@ -60,8 +93,6 @@ export function formatGroupTitle(_date: DateTime): string {
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
-export const formatDateGroupTitle = memoize(formatGroupTitle);
-
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
if (isTimelineAsset(unknownAsset)) {
return unknownAsset;
@@ -78,7 +109,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
ownerId: assetResponse.ownerId,
ratio,
thumbhash: assetResponse.thumbhash,
- localDateTime: assetResponse.localDateTime,
+ localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime),
isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility,
isTrashed: assetResponse.isTrashed,
@@ -93,5 +124,46 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
people,
};
};
+
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
(unknownAsset as TimelineAsset).ratio !== undefined;
+
+export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTime, b: TimelinePlainDateTime) => {
+ const [aDateTime, bDateTime] = ascending ? [a, b] : [b, a];
+
+ if (aDateTime.year !== bDateTime.year) {
+ return aDateTime.year - bDateTime.year;
+ }
+ if (aDateTime.month !== bDateTime.month) {
+ return aDateTime.month - bDateTime.month;
+ }
+ if (aDateTime.day !== bDateTime.day) {
+ return aDateTime.day - bDateTime.day;
+ }
+ if (aDateTime.hour !== bDateTime.hour) {
+ return aDateTime.hour - bDateTime.hour;
+ }
+ if (aDateTime.minute !== bDateTime.minute) {
+ return aDateTime.minute - bDateTime.minute;
+ }
+ if (aDateTime.second !== bDateTime.second) {
+ return aDateTime.second - bDateTime.second;
+ }
+ return aDateTime.millisecond - bDateTime.millisecond;
+};
+
+export type TimelinePlainDateTime = TimelinePlainDate & {
+ hour: number;
+ minute: number;
+ second: number;
+ millisecond: number;
+};
+
+export type TimelinePlainDate = TimelinePlainYearMonth & {
+ day: number;
+};
+
+export type TimelinePlainYearMonth = {
+ year: number;
+ month: number;
+};
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 ffeef43db1..0d2f9d79e3 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
@@ -174,7 +174,7 @@
const asset =
$slideshowNavigation === SlideshowNavigation.Shuffle
? await assetStore.getRandomAsset()
- : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
+ : assetStore.buckets[0]?.dateGroups[0]?.intersectingAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
}
diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts
index f68c3a1a1a..bca6a61d7d 100644
--- a/web/src/test-data/factories/asset-factory.ts
+++ b/web/src/test-data/factories/asset-factory.ts
@@ -1,4 +1,5 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
+import { fromLocalDateTimeToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
import { faker } from '@faker-js/faker';
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts';
@@ -33,7 +34,7 @@ export const timelineAssetFactory = Sync.makeFactory({
ratio: Sync.each(() => faker.number.int()),
ownerId: Sync.each(() => faker.string.uuid()),
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
- localDateTime: Sync.each(() => faker.date.past().toISOString()),
+ localDateTime: Sync.each(() => fromLocalDateTimeToObject(faker.date.past().toISOString())),
isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline,
isTrashed: false,
@@ -76,7 +77,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
bucketAssets.isImage.push(asset.isImage);
bucketAssets.isTrashed.push(asset.isTrashed);
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
- bucketAssets.localDateTime.push(asset.localDateTime);
+ bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO());
bucketAssets.ownerId.push(asset.ownerId);
bucketAssets.projectionType.push(asset.projectionType!);
bucketAssets.ratio.push(asset.ratio);