From e96ffd43e75b88663af026140d04f9c9fdbbd381 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 18 Mar 2025 10:14:46 -0400 Subject: [PATCH] feat: timeline performance (#16446) * Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming --- e2e/src/web/specs/shared-link.e2e-spec.ts | 2 +- web/package-lock.json | 10 +- web/package.json | 6 +- web/src/app.css | 25 +- web/src/lib/actions/intersection-observer.ts | 12 +- web/src/lib/actions/resize-observer.ts | 2 +- .../components/album-page/album-viewer.svelte | 8 +- .../asset-viewer/asset-viewer.svelte | 2 +- .../assets/thumbnail/image-thumbnail.svelte | 17 +- .../assets/thumbnail/thumbnail.svelte | 420 ++--- .../assets/thumbnail/video-thumbnail.svelte | 40 +- .../photos-page/asset-date-group.svelte | 305 ++- .../components/photos-page/asset-grid.svelte | 497 ++--- .../photos-page/measure-date-group.svelte | 91 - .../components/photos-page/skeleton.svelte | 32 +- .../shared-components/control-app-bar.svelte | 2 +- .../gallery-viewer/gallery-viewer.svelte | 199 +- .../scrubber/scrubber.svelte | 97 +- .../side-bar/purchase-info.svelte | 13 +- .../shared-components/tree/breadcrumbs.svelte | 2 +- .../duplicates-compare-control.svelte | 2 +- web/src/lib/constants.ts | 27 +- .../lib/stores/asset-interaction.svelte.ts | 11 +- web/src/lib/stores/assets-store.spec.ts | 219 ++- web/src/lib/stores/assets-store.svelte.ts | 1651 ++++++++++------- web/src/lib/stores/timeline.store.ts | 3 - web/src/lib/utils/asset-store-task-manager.ts | 465 ----- web/src/lib/utils/asset-utils.ts | 2 +- web/src/lib/utils/cancellable-task.ts | 135 ++ web/src/lib/utils/idle-callback-support.ts | 22 - web/src/lib/utils/keyed-priority-queue.ts | 50 - web/src/lib/utils/layout-utils.ts | 46 +- web/src/lib/utils/priority-queue.ts | 21 - web/src/lib/utils/timeline-util.ts | 87 +- web/src/lib/utils/tunables.ts | 45 +- .../[[assetId=id]]/+page.svelte | 278 +-- .../[[assetId=id]]/+page.svelte | 29 +- .../[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 14 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 8 +- web/src/routes/(user)/people/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 23 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 18 +- .../[[assetId=id]]/+page.svelte | 97 +- .../[[assetId=id]]/+page.svelte | 10 +- .../[[assetId=id]]/+page.svelte | 18 +- web/tsconfig.json | 2 +- 48 files changed, 2318 insertions(+), 2764 deletions(-) delete mode 100644 web/src/lib/components/photos-page/measure-date-group.svelte delete mode 100644 web/src/lib/stores/timeline.store.ts delete mode 100644 web/src/lib/utils/asset-store-task-manager.ts create mode 100644 web/src/lib/utils/cancellable-task.ts delete mode 100644 web/src/lib/utils/idle-callback-support.ts delete mode 100644 web/src/lib/utils/keyed-priority-queue.ts delete mode 100644 web/src/lib/utils/priority-queue.ts diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index ed81db4ef5..9313526dab 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -45,7 +45,7 @@ test.describe('Shared Links', () => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.locator(`[data-asset-id="${asset.id}"]`).hover(); - await page.waitForSelector('#asset-group-by-date svg'); + await page.waitForSelector('[data-group] svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); await page.getByText('DOWNLOADING', { exact: true }).waitFor(); diff --git a/web/package-lock.json b/web/package-lock.json index 6b2a0e9bfb..a2da0e1ae9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -70,8 +70,8 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^5.14.0", - "svelte": "^5.17.4", - "svelte-check": "^4.1.4", + "svelte": "^5.22.6", + "svelte-check": "^4.1.5", "tailwindcss": "^3.4.17", "tslib": "^2.6.2", "typescript": "^5.7.3", @@ -9579,9 +9579,9 @@ } }, "node_modules/vite": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", - "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index af45e08659..77da2d24cb 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build:stats": "BUILD_STATS=true vite build", "package": "svelte-kit package", "preview": "vite preview", - "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'", + "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte", "check:typescript": "tsc --noEmit", "check:watch": "npm run check:svelte -- --watch", "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", @@ -56,8 +56,8 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^5.14.0", - "svelte": "^5.17.4", - "svelte-check": "^4.1.4", + "svelte": "^5.22.6", + "svelte-check": "^4.1.5", "tailwindcss": "^3.4.17", "tslib": "^2.6.2", "typescript": "^5.7.3", diff --git a/web/src/app.css b/web/src/app.css index 9bc1695a8f..a256cc9d80 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -135,32 +135,13 @@ input:focus-visible { } /* width */ - .immich-scrollbar::-webkit-scrollbar { - width: 8px; - } - - /* Track */ - .immich-scrollbar::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 16px; - } - - /* Handle */ - .immich-scrollbar::-webkit-scrollbar-thumb { - background: rgba(85, 86, 87, 0.408); - border-radius: 16px; - } - - /* Handle on hover */ - .immich-scrollbar::-webkit-scrollbar-thumb:hover { - background: #4250afad; - border-radius: 16px; + .immich-scrollbar { + scrollbar-width: thin; } /* Hidden scrollbar */ /* width */ - .scrollbar-hidden::-webkit-scrollbar { - display: none; + .scrollbar-hidden { scrollbar-width: none; } diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index 3a10074051..74643aa95d 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -13,6 +13,7 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem type OnSeparateCallback = (element: HTMLElement) => unknown; type IntersectionObserverActionProperties = { key?: string; + disabled?: boolean; /** Function to execute when the element leaves the viewport */ onSeparate?: OnSeparateCallback; /** Function to execute when the element enters the viewport */ @@ -83,8 +84,15 @@ const observe = (key: HTMLElement | string, target: HTMLElement, properties: Int }; function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) { - elementToConfig.set(key, properties); - observe(key, element, properties); + if (properties.disabled) { + const config = elementToConfig.get(key); + const { observer } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); + } else { + elementToConfig.set(key, properties); + observe(key, element, properties); + } } function _intersectionObserver( diff --git a/web/src/lib/actions/resize-observer.ts b/web/src/lib/actions/resize-observer.ts index 9f3adc44b0..4fa35c7d93 100644 --- a/web/src/lib/actions/resize-observer.ts +++ b/web/src/lib/actions/resize-observer.ts @@ -1,4 +1,4 @@ -type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; +export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void; let observer: ResizeObserver; let callbacks: WeakMap; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 8b5b2bff8b..c176402576 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -33,7 +33,10 @@ let { isViewing: showAssetViewer } = assetViewingStore; - const assetStore = new AssetStore({ albumId: album.id, order: album.order }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ albumId: album.id, order: album.order })); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); dragAndDropFilesStore.subscribe((value) => { @@ -42,9 +45,6 @@ dragAndDropFilesStore.set({ isDragging: false, files: [] }); } }); - onDestroy(() => { - assetStore.destroy(); - }); void; onNext: () => Promise; onPrevious: () => Promise; - onRandom: () => Promise; + onRandom: () => Promise; copyImage?: () => Promise; } diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 9d69bdeeb2..119efe71b5 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -4,7 +4,6 @@ import Icon from '$lib/components/elements/icon.svelte'; import { TUNABLES } from '$lib/utils/tunables'; import { mdiEyeOffOutline } from '@mdi/js'; - import { onMount } from 'svelte'; import { fade } from 'svelte/transition'; interface Props { @@ -37,7 +36,6 @@ circle = false, hidden = false, border = false, - preload = true, hiddenIconClass = 'text-white', onComplete = undefined, }: Props = $props(); @@ -49,8 +47,6 @@ let loaded = $state(false); let errored = $state(false); - let img = $state(); - const setLoaded = () => { loaded = true; onComplete?.(); @@ -59,11 +55,13 @@ errored = true; onComplete?.(); }; - onMount(() => { - if (img?.complete) { - setLoaded(); + + function mount(elem: HTMLImageElement) { + if (elem.complete) { + loaded = true; + onComplete?.(); } - }); + } let optionalClasses = $derived( [ @@ -82,10 +80,9 @@ {:else} - import { intersectionObserver } from '$lib/actions/intersection-observer'; import Icon from '$lib/components/elements/icon.svelte'; import { ProjectionType } from '$lib/constants'; import { getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; @@ -22,19 +21,11 @@ import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; - import { AssetStore } from '$lib/stores/assets-store.svelte'; - - import type { DateGroup } from '$lib/utils/timeline-util'; - - import { generateId } from '$lib/utils/generate-id'; - import { onDestroy } from 'svelte'; import { TUNABLES } from '$lib/utils/tunables'; import { thumbhash } from '$lib/actions/thumbhash'; interface Props { asset: AssetResponseDto; - dateGroup?: DateGroup | undefined; - assetStore?: AssetStore | undefined; groupIndex?: number; thumbnailSize?: number | undefined; thumbnailWidth?: number | undefined; @@ -47,29 +38,16 @@ showArchiveIcon?: boolean; showStackedIcon?: boolean; disableMouseOver?: boolean; - intersectionConfig?: { - root?: HTMLElement; - bottom?: string; - top?: string; - left?: string; - priority?: number; - disabled?: boolean; - }; - retrieveElement?: boolean; - onIntersected?: (() => void) | undefined; + onClick?: ((asset: AssetResponseDto) => void) | undefined; - onRetrieveElement?: ((elment: HTMLElement) => void) | undefined; onSelect?: ((asset: AssetResponseDto) => void) | undefined; onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; handleFocus?: (() => void) | undefined; class?: string; - overrideDisplayForTest?: boolean; } let { - asset, - dateGroup = undefined, - assetStore = undefined, + asset = $bindable(), groupIndex = 0, thumbnailSize = undefined, thumbnailWidth = undefined, @@ -82,42 +60,21 @@ showArchiveIcon = false, showStackedIcon = true, disableMouseOver = false, - intersectionConfig = {}, - retrieveElement = false, - onIntersected = undefined, onClick = undefined, - onRetrieveElement = undefined, onSelect = undefined, onMouseEvent = undefined, handleFocus = undefined, class: className = '', - overrideDisplayForTest = false, }: Props = $props(); let { IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION }, } = TUNABLES; - const componentId = generateId(); - let element: HTMLElement | undefined = $state(); let focussableElement: HTMLElement | undefined = $state(); let mouseOver = $state(false); - let intersecting = $state(false); - let lastRetrievedElement: HTMLElement | undefined = $state(); let loaded = $state(false); - $effect(() => { - if (!retrieveElement) { - lastRetrievedElement = undefined; - } - }); - $effect(() => { - if (retrieveElement && element && lastRetrievedElement !== element) { - lastRetrievedElement = element; - onRetrieveElement?.(element); - } - }); - $effect(() => { if (focussed && document.activeElement !== focussableElement) { focussableElement?.focus(); @@ -126,13 +83,12 @@ let width = $derived(thumbnailSize || thumbnailWidth || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235); - let display = $derived(intersecting); const onIconClickedHandler = (e?: MouseEvent) => { e?.stopPropagation(); e?.preventDefault(); if (!disabled) { - onSelect?.(asset); + onSelect?.($state.snapshot(asset)); } }; @@ -141,7 +97,7 @@ onIconClickedHandler(); return; } - onClick?.(asset); + onClick?.($state.snapshot(asset)); }; const handleClick = (e: MouseEvent) => { if (e.ctrlKey || e.metaKey) { @@ -152,68 +108,18 @@ callClickHandlers(); }; - const _onMouseEnter = () => { + const onMouseEnter = () => { mouseOver = true; onMouseEvent?.({ isMouseOver: true, selectedGroupIndex: groupIndex }); }; - const onMouseEnter = () => { - if (dateGroup && assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => _onMouseEnter() }); - } else { - _onMouseEnter(); - } - }; - const onMouseLeave = () => { - if (dateGroup && assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ componentId, task: () => (mouseOver = false) }); - } else { - mouseOver = false; - } + mouseOver = false; }; - - const _onIntersect = () => { - intersecting = true; - onIntersected?.(); - }; - - const onIntersect = () => { - if (intersecting === true) { - return; - } - if (dateGroup && assetStore) { - assetStore.taskManager.intersectedThumbnail(componentId, dateGroup, asset, () => void _onIntersect()); - } else { - void _onIntersect(); - } - }; - - const onSeparate = () => { - if (intersecting === false) { - return; - } - if (dateGroup && assetStore) { - assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false)); - } else { - intersecting = false; - } - }; - - onDestroy(() => { - assetStore?.taskManager.removeAllTasksForComponent(componentId); - }); diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index a2e30be543..fc3cb2e951 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -3,12 +3,8 @@ import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; - import { AssetStore } from '$lib/stores/assets-store.svelte'; - import { generateId } from '$lib/utils/generate-id'; - import { onDestroy } from 'svelte'; interface Props { - assetStore?: AssetStore | undefined; url: string; durationInSeconds?: number; enablePlayback?: boolean; @@ -20,7 +16,6 @@ } let { - assetStore = undefined, url, durationInSeconds = 0, enablePlayback = $bindable(false), @@ -31,7 +26,6 @@ pauseIcon = mdiPauseCircleOutline, }: Props = $props(); - const componentId = generateId(); let remainingSeconds = $state(durationInSeconds); let loading = $state(true); let error = $state(false); @@ -49,42 +43,16 @@ } }); const onMouseEnter = () => { - if (assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - if (playbackOnIconHover) { - enablePlayback = true; - } - }, - }); - } else { - if (playbackOnIconHover) { - enablePlayback = true; - } + if (playbackOnIconHover) { + enablePlayback = true; } }; const onMouseLeave = () => { - if (assetStore) { - assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - if (playbackOnIconHover) { - enablePlayback = false; - } - }, - }); - } else { - if (playbackOnIconHover) { - enablePlayback = false; - } + if (playbackOnIconHover) { + enablePlayback = false; } }; - - onDestroy(() => { - assetStore?.taskManager.removeAllTasksForComponent(componentId); - });
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 4cc43ef199..e993d3694d 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,56 +1,51 @@ -
- {#each dateGroups as dateGroup, groupIndex (dateGroup.date)} - {@const display = - dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)} - {@const geometry = dateGroup.geometry!} +{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)} + {@const absoluteWidth = dateGroup.left} + +
{ + isMouseOverGroup = true; + assetMouseEventHandler(dateGroup.groupTitle, null); + }} + onmouseleave={() => { + isMouseOverGroup = false; + assetMouseEventHandler(dateGroup.groupTitle, null); + }} + > +
{ - assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () => - assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }), - ); - }, - onSeparate: () => { - assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => - assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), - ); - }, - top: INTERSECTION_ROOT_TOP, - bottom: INTERSECTION_ROOT_BOTTOM, - root: assetGridElement, - }} - data-display={display} - data-date-group={dateGroup.date} - style:height={dateGroup.height + 'px'} - style:width={geometry.containerWidth + 'px'} - style:overflow="clip" + class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" + style:width={dateGroup.width + 'px'} > - {#if !display} - - {/if} - {#if display} - - + {#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
- assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - isMouseOverGroup = true; - assetMouseEventHandler(dateGroup.groupTitle, null); - }, - })} - on:mouseleave={() => { - assetStore.taskManager.queueScrollSensitiveTask({ - componentId, - task: () => { - isMouseOverGroup = false; - assetMouseEventHandler(dateGroup.groupTitle, null); - }, - }); - }} + transition:fly={{ x: -24, duration: 200, opacity: 0.5 }} + class="inline-block px-2 hover:cursor-pointer" + onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))} + onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))} > - -
- {#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} -
handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} - on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)} - > - {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} - - {:else} - - {/if} -
- {/if} - - - {dateGroup.groupTitle} - -
- - -
- {#each dateGroup.assets as asset, i (asset.id)} - - {@const top = geometry.getTop(i)} - {@const left = geometry.getLeft(i)} - {@const width = geometry.getWidth(i)} - {@const height = geometry.getHeight(i)} - -
onAssetInGrid?.(asset), - top: `${-TITLE_HEIGHT}px`, - bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`, - right: `${-(viewport.width - 1)}px`, - root: assetGridElement, - }} - data-asset-id={asset.id} - class="absolute" - style:top={top + 'px'} - style:left={left + 'px'} - style:width={width + 'px'} - style:height={height + 'px'} - > - onRetrieveElement(dateGroup, asset, element)} - showStackedIcon={withStacked} - {showArchiveIcon} - {asset} - {groupIndex} - onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)} - onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)} - onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)} - selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)} - handleFocus={() => assetOnFocusHandler(asset)} - focussed={assetInteraction.isFocussedAsset(asset)} - selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} - disabled={assetStore.albumAssets.has(asset.id)} - thumbnailWidth={width} - thumbnailHeight={height} - /> -
- {/each} -
+ {#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)} + + {:else} + + {/if}
{/if} + + + {dateGroup.groupTitle} +
- {/each} -
+ + +
+ {#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)} + {@const position = intersectingAsset.position!} + {@const asset = intersectingAsset.asset!} + + + +
+ onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)} + onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)} + onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))} + selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + handleFocus={() => assetOnFocusHandler(asset)} + disabled={dateGroup.bucket.store.albumAssets.has(asset.id)} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> +
+ + {/each} +
+
+{/each} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 1f4f9aca85..970f09793f 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -4,38 +4,26 @@ import type { Action } from '$lib/components/asset-viewer/actions/action'; import { AppRoute, AssetAction } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte'; - import { locale, showDeleteModal } from '$lib/stores/preferences.store'; + import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte'; + import { showDeleteModal } from '$lib/stores/preferences.store'; import { isSearchEnabled } from '$lib/stores/search.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets } from '$lib/utils/actions'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { navigate } from '$lib/utils/navigation'; - import { - formatGroupTitle, - splitBucketIntoDateGroups, - type ScrubberListener, - type ScrollTargetListener, - } from '$lib/utils/timeline-util'; - import { TUNABLES } from '$lib/utils/tunables'; + import { type ScrubberListener } from '$lib/utils/timeline-util'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; - import { throttle } from 'lodash-es'; - import { onDestroy, onMount, type Snippet } from 'svelte'; + import { onMount, type Snippet } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import AssetDateGroup from './asset-date-group.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte'; - - import { resizeObserver } from '$lib/actions/resize-observer'; - import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte'; - import { intersectionObserver } from '$lib/actions/intersection-observer'; + import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import Skeleton from '$lib/components/photos-page/skeleton.svelte'; import { page } from '$app/stores'; import type { UpdatePayload } from 'vite'; - import { generateId } from '$lib/utils/generate-id'; - import { isTimelineScrolling } from '$lib/stores/timeline.store'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; interface Props { @@ -81,64 +69,41 @@ let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; - const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); - const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 }); - - const componentId = generateId(); let element: HTMLElement | undefined = $state(); + let timelineElement: HTMLElement | undefined = $state(); let showShortcuts = $state(false); let showSkeleton = $state(true); - let internalScroll = false; - let navigating = false; - let preMeasure: AssetBucket[] = $state([]); - let lastIntersectedBucketDate: string | undefined; let scrubBucketPercent = $state(0); let scrubBucket: { bucketDate: string | undefined } | undefined = $state(); let scrubOverallPercent: number = $state(0); - let topSectionHeight = $state(0); - let topSectionOffset = $state(0); + // 60 is the bottom spacer element at 60px let bottomSectionHeight = 60; let leadout = $state(false); - const { - ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW }, - BUCKET: { - INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP, - INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM, - }, - THUMBNAIL: { - INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP, - INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM, - }, - } = TUNABLES; - - const isViewportOrigin = () => { - return viewport.height === 0 && viewport.width === 0; - }; - const isEqual = (a: ViewportXY, b: ViewportXY) => { - return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y; - }; - - const completeNav = () => { - navigating = false; - if (internalScroll) { - internalScroll = false; - return; - } - + const completeNav = async () => { if ($gridScrollTarget?.at) { - void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => { + try { + const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at); + if (bucket) { + const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at); + if (height) { + element?.scrollTo({ top: height }); + showSkeleton = false; + assetStore.updateIntersections(); + } + } + } catch { element?.scrollTo({ top: 0 }); showSkeleton = false; - }); + } } else { element?.scrollTo({ top: 0 }); showSkeleton = false; } }; - + beforeNavigate(() => (assetStore.suspendTransitions = true)); afterNavigate((nav) => { const { complete, type } = nav; if (type === 'enter') { @@ -147,10 +112,6 @@ complete.then(completeNav, completeNav); }); - beforeNavigate(() => { - navigating = true; - }); - const hmrSupport = () => { // when hmr happens, skeleton is initialized to true by default // normally, loading asset-grid is part of a navigation event, and the completion of @@ -165,7 +126,6 @@ if (assetGridUpdate) { setTimeout(() => { - void assetStore.updateViewport(safeViewport, true); const asset = $page.url.searchParams.get('at'); if (asset) { $gridScrollTarget = { at: asset }; @@ -193,94 +153,60 @@ return () => void 0; }; - const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => { - if (lastIntersectedBucketDate) { - const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate); - const deltaIndex = assetStore.buckets.indexOf(adjustedBucket); - - if (deltaIndex < currentIndex) { - element?.scrollBy(0, delta); - } - } - }; - - const bucketListener: BucketListener = (event) => { - const { type } = event; - if (type === 'bucket-height') { - const { bucket, delta } = event; - scrollTolastIntersectedBucket(bucket, delta); - } - }; + 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: number) => element?.scrollBy(0, delta); + const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height); onMount(() => { - void assetStore - .init({ bucketListener }) - .then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport))); + assetStore.setCompensateScrollCallback(compensateScrollCallback); if (!enableRouting) { showSkeleton = false; } - const dispose = hmrSupport(); + const disposeHmr = hmrSupport(); return () => { - assetStore.disconnect(); - assetStore.destroy(); - dispose(); + assetStore.setCompensateScrollCallback(); + disposeHmr(); }; }); - const _updateViewport = () => void assetStore.updateViewport(safeViewport); - const updateViewport = throttle(_updateViewport, 16); - - function getOffset(bucketDate: string) { - let offset = 0; - for (let a = 0; a < assetStore.buckets.length; a++) { - if (assetStore.buckets[a].bucketDate === bucketDate) { - break; - } - offset += assetStore.buckets[a].bucketHeight; - } - return offset; - } - - const getMaxScrollPercent = () => - (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) / - (assetStore.timelineHeight + bottomSectionHeight + topSectionHeight); + const getMaxScrollPercent = () => { + const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight; + return (totalHeight - assetStore.viewportHeight) / totalHeight; + }; const getMaxScroll = () => { if (!element || !timelineElement) { return 0; } - - return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); + return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight); }; const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => { - const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset; + const topOffset = bucket.top; const maxScrollPercent = getMaxScrollPercent(); const delta = bucket.bucketHeight * bucketScrollPercent; const scrollTop = (topOffset + delta) * maxScrollPercent; - if (!element) { - return; + if (element) { + element.scrollTop = scrollTop; } - - element.scrollTop = scrollTop; }; - const _onScrub: ScrubberListener = ( + // note: don't throttle, debounch, or otherwise make this function async - it causes flicker + const onScrub: ScrubberListener = ( bucketDate: string | undefined, scrollPercent: number, bucketScrollPercent: number, ) => { - if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) { + if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) { // 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; } else { const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); @@ -290,47 +216,16 @@ scrollToBucketAndOffset(bucket, bucketScrollPercent); } }; - const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true }); - - const stopScrub: ScrubberListener = async ( - bucketDate: string | undefined, - _scrollPercent: number, - bucketScrollPercent: number, - ) => { - if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) { - // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead - return; - } - const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate); - if (!bucket) { - return; - } - if (bucket && !bucket.measured) { - preMeasure.push(bucket); - await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true }); - await bucket.measuredPromise; - scrollToBucketAndOffset(bucket, bucketScrollPercent); - } - }; - - let scrollObserverTimer: NodeJS.Timeout; - - const _handleTimelineScroll = () => { - $isTimelineScrolling = true; - if (scrollObserverTimer) { - clearTimeout(scrollObserverTimer); - } - scrollObserverTimer = setTimeout(() => { - $isTimelineScrolling = false; - }, 1000); + // note: don't throttle, debounch, or otherwise make this function async - it causes flicker + const handleTimelineScroll = () => { leadout = false; if (!element) { return; } - if (assetStore.timelineHeight < safeViewport.height * 2) { + if (assetStore.timelineHeight < assetStore.viewportHeight * 2) { // edge case - scroll limited due to size of content, must adjust - use the overall percent instead const maxScroll = getMaxScroll(); scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll); @@ -338,8 +233,8 @@ scrubBucket = undefined; scrubBucketPercent = 0; } else { - let top = element?.scrollTop; - if (top < topSectionHeight) { + let top = element.scrollTop; + if (top < assetStore.topSectionHeight) { // in the lead-in area scrubBucket = undefined; scrubBucketPercent = 0; @@ -352,18 +247,24 @@ let maxScrollPercent = getMaxScrollPercent(); let found = false; - // create virtual buckets.... - const vbuckets = [ - { bucketHeight: topSectionHeight, bucketDate: undefined }, - ...assetStore.buckets, - { bucketHeight: bottomSectionHeight, bucketDate: undefined }, - ]; - - for (const bucket of vbuckets) { - let next = top - bucket.bucketHeight * maxScrollPercent; + const bucketsLength = assetStore.buckets.length; + for (let i = -1; i < bucketsLength + 1; i++) { + let bucket: { bucketDate: string | undefined } | undefined; + let bucketHeight = 0; + if (i === -1) { + // lead-in + bucketHeight = assetStore.topSectionHeight; + } else if (i === bucketsLength) { + // lead-out + bucketHeight = bottomSectionHeight; + } else { + bucket = assetStore.buckets[i]; + bucketHeight = assetStore.buckets[i].bucketHeight; + } + let next = top - bucketHeight * maxScrollPercent; if (next < 0) { scrubBucket = bucket; - scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent); + scrubBucketPercent = top / (bucketHeight * maxScrollPercent); found = true; break; } @@ -377,34 +278,6 @@ } } }; - const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true }); - - const _onAssetInGrid = async (asset: AssetResponseDto) => { - if (!enableRouting || navigating || internalScroll) { - return; - } - $gridScrollTarget = { at: asset.id }; - internalScroll = true; - await navigate( - { targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }, - { replaceState: true, forceNavigate: true }, - ); - }; - const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW - ? throttle(_onAssetInGrid, 16, { leading: false, trailing: true }) - : () => void 0; - - const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => { - element?.scrollTo({ top: offset }); - if (!bucket.measured) { - preMeasure.push(bucket); - } - showSkeleton = false; - assetStore.clearPendingScroll(); - // set intersecting true manually here, to reduce flicker that happens when - // clearing pending scroll, but the intersection observer hadn't yet had time to run - assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); - }; const trashOrDelete = async (force: boolean = false) => { isShowDeleteConfirmation = false; @@ -439,11 +312,9 @@ }; const toggleArchive = async () => { - const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); - if (ids) { - assetStore.removeAssets(ids); - deselectAllAssets(); - } + await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); + assetStore.updateAssets(assetInteraction.selectedAssetsArray); + deselectAllAssets(); }; const focusElement = () => { @@ -458,23 +329,6 @@ } }; - function handleIntersect(bucket: AssetBucket) { - // updateLastIntersectedBucketDate(); - const task = () => { - assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); - void assetStore.loadBucket(bucket.bucketDate); - }; - assetStore.taskManager.intersectedBucket(componentId, bucket, task); - } - - function handleSeparate(bucket: AssetBucket) { - const task = () => { - assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); - bucket.cancel(); - }; - assetStore.taskManager.separatedBucket(componentId, bucket, task); - } - const handlePrevious = async () => { const previousAsset = await assetStore.getPreviousAsset($viewingAsset); @@ -610,7 +464,6 @@ if (!asset) { return; } - onSelect(asset); if (singleSelect && element) { @@ -619,7 +472,7 @@ } const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0; - const deselect = assetInteraction.selectedAssets.has(asset); + const deselect = assetInteraction.hasSelectedAsset(asset.id); // Select/deselect already loaded assets if (deselect) { @@ -637,39 +490,48 @@ assetInteraction.clearAssetSelectionCandidates(); if (assetInteraction.assetSelectionStart && rangeSelection) { - let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); - let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id); + let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id); + let endBucket = assetStore.getBucketIndexByAssetId(asset.id); - if (startBucketIndex === null || endBucketIndex === null) { + if (startBucket === null || endBucket === null) { return; } - if (endBucketIndex < startBucketIndex) { - [startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex]; - } - - // Select/deselect assets in all intermediate buckets - for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) { - const bucket = assetStore.buckets[bucketIndex]; - await assetStore.loadBucket(bucket.bucketDate); - for (const asset of bucket.assets) { - if (deselect) { - assetInteraction.removeAssetFromMultiselectGroup(asset); - } else { - handleSelectAsset(asset); + // Select/deselect assets in range (start,end] + let started = false; + for (const bucket of assetStore.buckets) { + if (bucket === startBucket) { + started = true; + } + if (bucket === endBucket) { + break; + } + if (started) { + await assetStore.loadBucket(bucket.bucketDate); + for (const asset of bucket.getAssets()) { + if (deselect) { + assetInteraction.removeAssetFromMultiselectGroup(asset); + } else { + handleSelectAsset(asset); + } } } } // Update date group selection - for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) { - const bucket = assetStore.buckets[bucketIndex]; + started = false; + for (const bucket of assetStore.buckets) { + if (bucket === startBucket) { + started = true; + } + if (bucket === endBucket) { + break; + } // Split bucket into date groups and check each group - const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale); - for (const dateGroup of assetsGroupByDate) { - const dateGroupTitle = formatGroupTitle(dateGroup.date); - if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) { + for (const dateGroup of bucket.dateGroups) { + const dateGroupTitle = dateGroup.groupTitle; + if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) { assetInteraction.addGroupToMultiselectGroup(dateGroupTitle); } else { assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle); @@ -691,14 +553,16 @@ return; } - let start = assetStore.assets.findIndex((a) => a.id === startAsset.id); - let end = assetStore.assets.findIndex((a) => a.id === endAsset.id); + const assets = 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(assetStore.assets.slice(start, end + 1)); + assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); }; const onSelectStart = (e: Event) => { @@ -710,14 +574,14 @@ const focusNextAsset = async () => { if (assetInteraction.focussedAssetId === null) { const firstAsset = assetStore.getFirstAsset(); - if (firstAsset !== null) { + if (firstAsset) { assetInteraction.focussedAssetId = firstAsset.id; } } else { - const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId); if (focussedAsset) { const nextAsset = await assetStore.getNextAsset(focussedAsset); - if (nextAsset !== null) { + if (nextAsset) { assetInteraction.focussedAssetId = nextAsset.id; } } @@ -726,7 +590,7 @@ const focusPreviousAsset = async () => { if (assetInteraction.focussedAssetId !== null) { - const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId); + const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId); if (focussedAsset) { const previousAsset = await assetStore.getPreviousAsset(focussedAsset); if (previousAsset) { @@ -736,11 +600,8 @@ } }; - onDestroy(() => { - assetStore.taskManager.removeAllTasksForComponent(componentId); - }); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); - let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0); + let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); $effect(() => { @@ -749,23 +610,6 @@ } }); - $effect(() => { - if (element && isViewportOrigin()) { - const rect = element.getBoundingClientRect(); - viewport.height = rect.height; - viewport.width = rect.width; - viewport.x = rect.x; - viewport.y = rect.y; - } - if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) { - safeViewport.height = viewport.height; - safeViewport.width = viewport.width; - safeViewport.x = viewport.x; - safeViewport.y = viewport.y; - updateViewport(); - } - }); - let shortcutList = $derived( (() => { if ($isSearchEnabled || $showAssetViewer) { @@ -829,19 +673,34 @@ {#if showShortcuts} (showShortcuts = !showShortcuts)} /> {/if} + {#if assetStore.buckets.length > 0} { + evt.preventDefault(); + let amount = 50; + if (shiftKeyIsDown) { + amount = 500; + } + if (evt.key === 'ArrowUp') { + amount = -amount; + if (shiftKeyIsDown) { + element?.scrollBy({ top: amount, behavior: 'smooth' }); + } + } else if (evt.key === 'ArrowDown') { + element?.scrollBy({ top: amount, behavior: 'smooth' }); + } + }} /> {/if} @@ -850,90 +709,67 @@ id="asset-grid" class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" tabindex="-1" - use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))} + bind:clientHeight={assetStore.viewportHeight} + bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())} bind:this={element} - onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())} + onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} > -
((topSectionHeight = height), (topSectionOffset = target.offsetTop))} - class:invisible={showSkeleton} - > - {@render children?.()} - {#if isEmpty} - - {@render empty?.()} - {/if} -
-
+
+ {@render children?.()} + {#if isEmpty} + + {@render empty?.()} + {/if} +
+ {#each assetStore.buckets as bucket (bucket.viewId)} - {@const isPremeasure = preMeasure.includes(bucket)} - {@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure} + {@const display = bucket.intersecting} + {@const absoluteHeight = bucket.top} -
handleIntersect(bucket), - onSeparate: () => handleSeparate(bucket), - top: BUCKET_INTERSECTION_ROOT_TOP, - bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, - root: element, - }, - { - key: bucket.viewId + '.bucketintersection', - onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate), - top: '0px', - bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px', - left: '0px', - right: '0px', - }, - ]} - data-bucket-display={bucket.intersecting} - data-bucket-date={bucket.bucketDate} - style:height={bucket.bucketHeight + 'px'} - > - {#if display && !bucket.measured} - (preMeasure = preMeasure.filter((b) => b !== bucket))} - > - {/if} - - {#if !display || !bucket.measured} - - {/if} - {#if display && bucket.measured} + {#if !bucket.isLoaded} +
+ +
+ {:else if display} +
handleGroupSelect(title, assets)} onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssets={handleSelectAssets} /> - {/if} -
+
+ {/if} {/each} -
+
@@ -965,6 +801,9 @@ } .bucket { - contain: layout size; + contain: layout size paint; + transform-style: flat; + backface-visibility: hidden; + transform-origin: center center; } diff --git a/web/src/lib/components/photos-page/measure-date-group.svelte b/web/src/lib/components/photos-page/measure-date-group.svelte deleted file mode 100644 index d3dabaa51d..0000000000 --- a/web/src/lib/components/photos-page/measure-date-group.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - - -
- {#each bucket.dateGroups as dateGroup (dateGroup.date)} -
-
assetStore.updateBucketDateGroup(bucket, dateGroup, { height })}> -
- - {dateGroup.groupTitle} - -
- -
-
-
- {/each} -
diff --git a/web/src/lib/components/photos-page/skeleton.svelte b/web/src/lib/components/photos-page/skeleton.svelte index 601a40cce2..9d1ba69aec 100644 --- a/web/src/lib/components/photos-page/skeleton.svelte +++ b/web/src/lib/components/photos-page/skeleton.svelte @@ -1,30 +1,28 @@ -
- {#if title} -
- {title} -
- {/if} -
+
+
+ {title} +
+
diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index ef0bf3cda7..dbd8ca5a61 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -69,7 +69,7 @@
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 e7f6bfc5f1..1625c92d3c 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 @@ -8,13 +8,11 @@ import type { Viewport } from '$lib/stores/assets-store.svelte'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { deleteAssets } from '$lib/utils/actions'; - import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils'; + import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { featureFlags } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; - import { calculateWidth } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; - import justifiedLayout from 'justified-layout'; import { t } from 'svelte-i18n'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import ShowShortcuts from '../show-shortcuts.svelte'; @@ -22,6 +20,8 @@ import { handlePromiseError } from '$lib/utils'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { debounce } from 'lodash-es'; + import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils'; interface Props { assets: AssetResponseDto[]; @@ -53,11 +53,84 @@ let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; + let geometry: CommonJustifiedLayout | undefined = $state(); + + $effect(() => { + const _assets = assets; + updateSlidingWindow(); + + geometry = getJustifiedLayoutFromAssets(_assets, { + spacing: 2, + heightTolerance: 0.15, + rowHeight: 235, + rowWidth: Math.floor(viewport.width), + }); + }); + + let assetLayouts = $derived.by(() => { + const assetLayout = []; + let containerHeight = 0; + let containerWidth = 0; + if (geometry) { + containerHeight = geometry.containerHeight; + containerWidth = geometry.containerWidth; + for (const [i, asset] of assets.entries()) { + const layout = { + asset, + top: geometry.getTop(i), + left: geometry.getLeft(i), + width: geometry.getWidth(i), + height: geometry.getHeight(i), + }; + // 54 is the content height of the asset-selection-app-bar + const layoutTopWithOffset = layout.top + 54; + const layoutBottom = layoutTopWithOffset + layout.height; + + const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; + assetLayout.push({ ...layout, display }); + } + } + + return { + assetLayout, + containerHeight, + containerWidth, + }; + }); + let showShortcuts = $state(false); let currentViewAssetIndex = 0; let shiftKeyIsDown = $state(false); let lastAssetMouseEvent: AssetResponseDto | null = $state(null); + let slidingWindow = $state({ top: 0, bottom: 0 }); + const updateSlidingWindow = () => { + const v = $state.snapshot(viewport); + const top = document.scrollingElement?.scrollTop || 0; + const bottom = top + v.height; + const w = { + top, + bottom, + }; + slidingWindow = w; + }; + const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); + + let lastIntersectedHeight = 0; + $effect(() => { + // notify we got to (near) the end of scroll + const scrollPercentage = + ((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) * + 100; + + if (scrollPercentage > 90) { + const intersectedHeight = geometry?.containerHeight || 0; + if (lastIntersectedHeight !== intersectedHeight) { + debouncedOnIntersected(); + lastIntersectedHeight = intersectedHeight; + } + } + }); const viewAssetHandler = async (asset: AssetResponseDto) => { currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); setAsset(assets[currentViewAssetIndex]); @@ -75,6 +148,7 @@ const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Shift') { event.preventDefault(); + shiftKeyIsDown = true; } }; @@ -90,7 +164,7 @@ if (!asset) { return; } - const deselect = assetInteraction.selectedAssets.has(asset); + const deselect = assetInteraction.hasSelectedAsset(asset.id); // Select/deselect already loaded assets if (deselect) { @@ -173,7 +247,7 @@ const toggleArchive = async () => { const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived); if (ids) { - assets.filter((asset) => !ids.includes(asset.id)); + assets = assets.filter((asset) => !ids.includes(asset.id)); deselectAllAssets(); } }; @@ -248,7 +322,7 @@ } }; - const handleRandom = async (): Promise => { + const handleRandom = async (): Promise => { try { let asset: AssetResponseDto | undefined; if (onRandom) { @@ -261,14 +335,14 @@ } if (!asset) { - return null; + return; } await navigateToAsset(asset); return asset; } catch (error) { handleError(error, $t('errors.cannot_navigate_next_asset')); - return null; + return; } }; @@ -335,26 +409,6 @@ let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id)); - let geometry = $derived( - (() => { - const justifiedLayoutResult = justifiedLayout( - assets.map((asset) => getAssetRatio(asset)), - { - boxSpacing: 2, - containerWidth: Math.floor(viewport.width), - containerPadding: 0, - targetRowHeightTolerance: 0.15, - targetRowHeight: 235, - }, - ); - - return { - ...justifiedLayoutResult, - containerWidth: calculateWidth(justifiedLayoutResult.boxes), - }; - })(), - ); - $effect(() => { if (!lastAssetMouseEvent) { assetInteraction.clearAssetSelectionCandidates(); @@ -374,7 +428,13 @@ }); - + updateSlidingWindow()} +/> {#if isShowDeleteConfirmation} 0} -
- {#each assets as asset, i (i)} -
- { - if (assetInteraction.selectionActive) { - handleSelectAssets(asset); - return; - } - void viewAssetHandler(asset); - }} - onSelect={(asset) => handleSelectAssets(asset)} - onMouseEvent={() => assetMouseEventHandler(asset)} - handleFocus={() => assetOnFocusHandler(asset)} - onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)} - {showArchiveIcon} - {asset} - selected={assetInteraction.selectedAssets.has(asset)} - focussed={assetInteraction.isFocussedAsset(asset)} - selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)} - thumbnailWidth={geometry.boxes[i].width} - thumbnailHeight={geometry.boxes[i].height} - /> - {#if showAssetName} -
- {asset.originalFileName} -
- {/if} -
+
+ {#each assetLayouts.assetLayout as layout (layout.asset.id)} + {@const asset = layout.asset} + + {#if layout.display} +
+ { + if (assetInteraction.selectionActive) { + handleSelectAssets(asset); + return; + } + void viewAssetHandler(asset); + }} + onSelect={(asset) => handleSelectAssets(asset)} + onMouseEvent={() => assetMouseEventHandler(asset)} + handleFocus={() => assetOnFocusHandler(asset)} + {showArchiveIcon} + {asset} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + focussed={assetInteraction.isFocussedAsset(asset)} + thumbnailWidth={layout.width} + thumbnailHeight={layout.height} + /> + {#if showAssetName} +
+ {asset.originalFileName} +
+ {/if} +
+ {/if} {/each}
{/if} diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index 729810d022..d13c12cf6a 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -1,10 +1,8 @@
@@ -445,7 +465,14 @@ {#if assetInteraction.isAllUserOwned} - + + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + > {/if} @@ -482,6 +509,7 @@ { + assetStore.suspendTransitions = true; viewMode = AlbumPageViewMode.SELECT_ASSETS; oldAt = { at: $gridScrollTarget?.at }; await navigate( @@ -576,127 +604,117 @@ {/if}
- - {#key albumKey} - {#if viewMode === AlbumPageViewMode.SELECT_ASSETS} - - {:else} - 0} - isSelectionMode={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} - singleSelect={viewMode === AlbumPageViewMode.SELECT_THUMBNAIL} - showArchiveIcon - onSelect={({ id }) => handleUpdateThumbnail(id)} - onEscape={handleEscape} - > - {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} - -
- (album.albumName = albumName)} - /> + + {#if viewMode !== AlbumPageViewMode.SELECT_ASSETS} + {#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL} + +
+ (album.albumName = albumName)} + /> - {#if album.assetCount > 0} - - {/if} + {#if album.assetCount > 0} + + {/if} - - {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)} -
- - {#if album.hasSharedLink && isOwned} - (viewMode = AlbumPageViewMode.LINK_SHARING)} - /> - {/if} + + {#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)} +
+ + {#if album.hasSharedLink && isOwned} + (viewMode = AlbumPageViewMode.LINK_SHARING)} + /> + {/if} - - - - - {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} - - {/each} - - - {#if albumHasViewers} - (viewMode = AlbumPageViewMode.VIEW_USERS)} - /> - {/if} - - {#if isOwned} - (viewMode = AlbumPageViewMode.SELECT_USERS)} - title={$t('add_more_users')} - /> - {/if} -
- {/if} - - -
- {/if} - - {#if album.assetCount === 0} -
-
-

{$t('add_photos').toUpperCase()}

- -
-
- {/if} -
- {/if} - {#if showActivityStatus} -
- -
+ + {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} + + {/each} + + + {#if albumHasViewers} + (viewMode = AlbumPageViewMode.VIEW_USERS)} + /> + {/if} + + {#if isOwned} + (viewMode = AlbumPageViewMode.SELECT_USERS)} + title={$t('add_more_users')} + /> + {/if} +
+ {/if} + + + + {/if} + + {#if album.assetCount === 0} +
+
+

{$t('add_photos').toUpperCase()}

+ +
+
+ {/if} {/if} - {/key} + + + {#if showActivityStatus} +
+ +
+ {/if}
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer} diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index c8b239218a..86cfefff77 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -12,20 +12,23 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { AssetAction } from '$lib/constants'; - import { AssetStore } from '$lib/stores/assets-store.svelte'; + import type { PageData } from './$types'; import { mdiPlus, mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { AssetStore } from '$lib/stores/assets-store.svelte'; interface Props { data: PageData; } let { data }: Props = $props(); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isArchived: true }); + onDestroy(() => assetStore.destroy()); - const assetStore = new AssetStore({ isArchived: true }); const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -34,10 +37,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); {#if assetInteraction.selectionActive} @@ -45,14 +44,28 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > - assetStore.removeAssets(assetIds)} /> + + assetStore.updateAssetOperation(ids, (asset) => { + asset.isArchived = isArchived; + return { remove: false }; + })} + /> - + + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + /> assetStore.removeAssets(assetIds)} /> diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 02cac3644d..120281b07e 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -29,7 +29,10 @@ let { data }: Props = $props(); - const assetStore = new AssetStore({ isFavorite: true }); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isFavorite: true }); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -38,10 +41,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index c412aa8ea2..160c236049 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -98,7 +98,19 @@ cancelMultiselect(assetInteraction)} /> cancelMultiselect(assetInteraction)} shared /> - + { + if (data.pathAssets && data.pathAssets.length > 0) { + for (const id of ids) { + const asset = data.pathAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + } + }} + /> diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2239a21cd5..0a71f35ff2 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -123,7 +123,7 @@ async function navigateRandom() { if (viewingAssets.length <= 0) { - return null; + return undefined; } const index = Math.floor(Math.random() * viewingAssets.length); const asset = await setAssetId(viewingAssets[index]); diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7885086b44..22a0d82cf4 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -21,7 +21,9 @@ let { data }: Props = $props(); - const assetStore = new AssetStore({ userId: data.partner.id, isArchived: false, withStacked: true }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true })); + onDestroy(() => assetStore.destroy()); const assetInteraction = new AssetInteraction(); const handleEscape = () => { @@ -30,10 +32,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - });
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f5b7d13112..45e18fd398 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -456,10 +456,10 @@ {#if selectHidden} - + {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6cad217377..c41ae7d85c 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -74,14 +74,9 @@ let numberOfAssets = $state(data.statistics.assets); let { isViewing: showAssetViewer } = assetViewingStore; - const assetStoreOptions = { isArchived: false, personId: data.person.id }; - const assetStore = new AssetStore(assetStoreOptions); - - $effect(() => { - // Check to trigger rebuild the timeline when navigating between people from the info panel - assetStoreOptions.personId = data.person.id; - handlePromiseError(assetStore.updateOptions(assetStoreOptions)); - }); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id })); + onDestroy(() => assetStore.destroy()); const assetInteraction = new AssetInteraction(); @@ -360,9 +355,6 @@ await updateAssetCount(); }; - onDestroy(() => { - assetStore.destroy(); - }); let person = $derived(data.person); let thumbnailData = $derived(getPeopleThumbnailUrl(person)); @@ -418,7 +410,14 @@ - + + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + /> assetStore.destroy()); + const assetInteraction = new AssetInteraction(); let selectedAssets = $derived(assetInteraction.selectedAssetsArray); @@ -67,10 +70,6 @@ assetStore.updateAssets([still]); }; - onDestroy(() => { - assetStore.destroy(); - }); - beforeNavigate(() => { isFaceEditMode.value = false; }); @@ -88,7 +87,14 @@ - + + assetStore.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + > {#if assetInteraction.selectedAssets.size > 1 || isAssetStackSelected} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index a35a30c1c4..c7f62cba0b 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -136,13 +136,17 @@ nextPage = 1; searchResultAssets = []; searchResultAlbums = []; - await loadNextPage(); + await loadNextPage(true); } - const loadNextPage = async () => { + // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages + export const loadNextPage = async (force?: boolean) => { if (!nextPage || searchResultAssets.length >= MAX_ASSET_COUNT) { return; } + if (isLoading && !force) { + return; + } isLoading = true; const searchDto: SearchTerms = { @@ -232,9 +236,6 @@ return tagNames.join(', '); } - // eslint-disable-next-line no-self-assign - const triggerAssetUpdate = () => (searchResultAssets = searchResultAssets); - const onAddToAlbum = (assetIds: string[]) => { if (terms.isNotInAlbum.toString() == 'true') { const assetIdSet = new Set(assetIds); @@ -262,13 +263,23 @@ - + { + for (const id of ids) { + const asset = searchResultAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + }} + /> - + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} {/if} @@ -281,6 +292,10 @@ {:else}
goto(previousRoute)} backIcon={mdiArrowLeft}> +
@@ -329,45 +344,43 @@ {/if}
-
- {#if searchResultAlbums.length > 0} -
-
{$t('albums').toUpperCase()}
- + {#if searchResultAlbums.length > 0} +
+
{$t('albums').toUpperCase()}
+ -
- {$t('photos_and_videos').toUpperCase()} -
-
- {/if} -
- {#if searchResultAssets.length > 0} - - {:else if !isLoading} -
-
- -

{$t('no_results')}

-

{$t('no_results_description')}

-
-
- {/if} - - {#if isLoading} -
- -
- {/if} +
+ {$t('photos_and_videos').toUpperCase()} +
+ {/if} +
+ {#if searchResultAssets.length > 0} + + {:else if !isLoading} +
+
+ +

{$t('no_results')}

+

{$t('no_results_description')}

+
+
+ {/if} + + {#if isLoading} +
+ +
+ {/if}
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 96bebe34c1..a89da7ad6b 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -24,6 +24,7 @@ import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { onDestroy } from 'svelte'; interface Props { data: PageData; @@ -39,8 +40,9 @@ const buildMap = (tags: TagResponseDto[]) => { return Object.fromEntries(tags.map((tag) => [tag.value, tag])); }; - - const assetStore = new AssetStore({}); + const assetStore = new AssetStore(); + $effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId })); + onDestroy(() => assetStore.destroy()); let tags = $state([]); $effect(() => { @@ -52,10 +54,6 @@ let tagId = $derived(tag?.id); let tree = $derived(buildTree(tags.map((tag) => tag.value))); - $effect.pre(() => { - void assetStore.updateOptions({ tagId }); - }); - const handleNavigation = async (tag: string) => { await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`)); }; diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index e31929f2c5..209f75a302 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -36,8 +36,10 @@ handlePromiseError(goto(AppRoute.PHOTOS)); } - const options = { isTrashed: true }; - const assetStore = new AssetStore(options); + const assetStore = new AssetStore(); + void assetStore.updateOptions({ isTrashed: true }); + onDestroy(() => assetStore.destroy()); + const assetInteraction = new AssetInteraction(); const handleEmptyTrash = async () => { @@ -56,9 +58,6 @@ message: $t('assets_permanently_deleted_count', { values: { count } }), type: NotificationType.Info, }); - - // reset asset grid (TODO fix in asset store that it should reset when it is empty) - await assetStore.updateOptions(options); } catch (error) { handleError(error, $t('errors.unable_to_empty_trash')); } @@ -80,7 +79,10 @@ }); // reset asset grid (TODO fix in asset store that it should reset when it is empty) - await assetStore.updateOptions(options); + // note - this is still a problem, but updateOptions with the same value will not + // do anything, so need to flip it for it to reload/reinit + // await assetStore.updateOptions({ deferInit: true, isTrashed: true }); + // await assetStore.updateOptions({ deferInit: false, isTrashed: true }); } catch (error) { handleError(error, $t('errors.unable_to_restore_trash')); } @@ -92,10 +94,6 @@ return; } }; - - onDestroy(() => { - assetStore.destroy(); - }); {#if assetInteraction.selectionActive} diff --git a/web/tsconfig.json b/web/tsconfig.json index 31aef23e31..c7bc16f52b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -4,7 +4,7 @@ "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "module": "es2020", + "module": "es2022", "moduleResolution": "bundler", "resolveJsonModule": true, "skipLibCheck": true,