mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: keyboard navigation to timeline (#17798)
* feat: improve focus * feat: keyboard nav * feat: improve focus * typo * test * fix test * lint * bad merge * lint * inadvertent * lint * fix: flappy e2e test * bad merge and fix tests * use modulus in loop * tests * react to modal dialog refactor * regression due to deferLayout * Review comments * Re-use change-date instead of new component * bad merge * Review comments * rework moveFocus * lint * Fix outline * use Date * Finish up removing/reducing date parsing * lint * title * strings * Rework dates, rework earlier/later algorithm * bad merge * fix tests * Fix race in scroll comp * consolidate scroll methods * Review comments * console.log * Edge cases in scroll compensation * edge case, optimizations * review comments * lint * lint * More edge cases * lint --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
b5593823a2
commit
f029910dc7
@ -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",
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-asset={asset.id}
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'bg-immich-primary/20 dark:bg-immich-dark-primary/20',
|
||||
]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
use:longPress={{ onLongPress: () => 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"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
@ -211,36 +238,10 @@
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- as of iOS17, there is a preference for long press speed, which is not available for mobile web.
|
||||
The defaults are as follows:
|
||||
fast: 200ms
|
||||
default: 500ms
|
||||
slow: ??ms
|
||||
-->
|
||||
<div
|
||||
class={['group absolute -top-[0px] -bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
style:height="inherit"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
use:longPress={{ onLongPress: () => 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"
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
@ -265,13 +266,6 @@
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
{/if}
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'absolute size-full group-focus-visible:outline-immich-primary focus:outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
78
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
78
web/src/lib/components/photos-page/actions/focus-actions.ts
Normal file
@ -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();
|
||||
};
|
@ -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<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
}
|
||||
|
||||
$effect.root(() => {
|
||||
if (assetStore.scrollCompensation.bucket === bucket) {
|
||||
onScrollCompensation(assetStore.scrollCompensation);
|
||||
assetStore.clearScrollCompensation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.day)}
|
||||
{@const absoluteWidth = dateGroup.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@ -146,7 +153,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={getDateLocaleString(dateGroup.date)}>
|
||||
<span class="w-full truncate first-letter:capitalize ms-2.5" title={dateGroup.groupTitle}>
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
@ -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!}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -735,6 +761,22 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowSelectDate}
|
||||
<ChangeDate
|
||||
title="Navigate to Time"
|
||||
initialDate={DateTime.now()}
|
||||
timezoneInput={false}
|
||||
onConfirm={async (dateString: string) => {
|
||||
isShowSelectDate = false;
|
||||
const asset = await assetStore.getClosestAssetToDate((DateTime.fromISO(dateString) as DateTime<true>).toObject());
|
||||
if (asset) {
|
||||
setFocusAsset(asset);
|
||||
}
|
||||
}}
|
||||
onCancel={() => (isShowSelectDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if assetStore.buckets.length > 0}
|
||||
<Scrubber
|
||||
{assetStore}
|
||||
@ -828,6 +870,7 @@
|
||||
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
onScrollCompensation={handleScrollCompensation}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -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 @@
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('edit_date_and_time')}
|
||||
{title}
|
||||
prompt="Please select a new date:"
|
||||
disabled={!date.isValid}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
@ -148,15 +157,17 @@
|
||||
<label for="datetime">{$t('date_and_time')}</label>
|
||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||
</div>
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{#if timezoneInput}
|
||||
<div>
|
||||
<Combobox
|
||||
bind:selectedOption
|
||||
label={$t('timezone')}
|
||||
options={timezones}
|
||||
placeholder={$t('search_timezone')}
|
||||
onSelect={(option) => handleOnSelect(option)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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}
|
||||
</div>
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment (segment.date)}
|
||||
{#each segments as segment (segment.year + '-' + segment.month)}
|
||||
<div
|
||||
class="relative"
|
||||
data-id="time-segment"
|
||||
data-time-segment-bucket-date={segment.date}
|
||||
data-time-segment-bucket-date={segment.year + '-' + segment.month}
|
||||
data-label={segment.dateFormatted}
|
||||
style:height={segment.height + 'px'}
|
||||
>
|
||||
{#if !usingMobileDevice}
|
||||
{#if segment.hasLabel}
|
||||
<div class="absolute end-5 top-[-16px] text-[12px] dark:text-immich-dark-fg font-immich-mono">
|
||||
{segment.date.year}
|
||||
{segment.year}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.hasDot}
|
||||
|
@ -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') },
|
||||
|
@ -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<string, TimelineAsset[]> = {
|
||||
'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<string, TimeBucketAssetResponseDto> = 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<string, TimelineAsset[]> = {
|
||||
'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<string, TimeBucketAssetResponseDto> = 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<string, TimelineAsset[]> = {
|
||||
'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<string, TimeBucketAssetResponseDto> = 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
@ -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);
|
||||
};
|
||||
|
53
web/src/lib/utils/invocationTracker.ts
Normal file
53
web/src/lib/utils/invocationTracker.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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<void>;
|
||||
@ -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<true>).toObject();
|
||||
|
||||
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
|
||||
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
|
||||
|
||||
export const fromTimelinePlainDate = (timelineYearMonth: TimelinePlainDate): DateTime<true> =>
|
||||
DateTime.fromObject(
|
||||
{ year: timelineYearMonth.year, month: timelineYearMonth.month, day: timelineYearMonth.day },
|
||||
{ zone: 'local', locale: get(locale) },
|
||||
) as DateTime<true>;
|
||||
|
||||
export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearMonth): DateTime<true> =>
|
||||
DateTime.fromObject(
|
||||
{ year: timelineYearMonth.year, month: timelineYearMonth.month },
|
||||
{ zone: 'local', locale: get(locale) },
|
||||
) as DateTime<true>;
|
||||
|
||||
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<true>).toISO();
|
||||
|
||||
export function formatBucketTitle(_date: DateTime): string {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
}
|
||||
const date = _date as DateTime<true>;
|
||||
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;
|
||||
};
|
||||
|
@ -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)));
|
||||
}
|
||||
|
@ -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<TimelineAsset>({
|
||||
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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user