perf(web): batch folder counts, bounded scroll scan, adaptive thumbs, lazy preview

Six-item frontend performance pass on the SvelteKit app.

P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.

P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.

P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".

P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.

P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.

P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:46:10 +02:00
parent 9a3ad3e579
commit 6b8c7abc20
10 changed files with 300 additions and 49 deletions

View File

@@ -4,6 +4,7 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { Component } from 'svelte';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
@@ -12,7 +13,7 @@
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
import { resizable } from '$lib/actions/resizable';
import { queryClient } from '$lib/queryClient';
import PreviewOverlay from '$lib/components/preview/PreviewOverlay.svelte';
import { preview } from '$lib/stores/preview.svelte';
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
@@ -42,6 +43,23 @@
void goto('/login', { replaceState: true });
}
});
// PreviewOverlay is the full-screen lightbox — keyboard nav, map
// pane, exif sidebar. Users who never click into a photo never need
// it, so we lazy-import the first time `preview.uid` flips non-null
// and keep the loaded module around for the rest of the session
// (re-opens skip the network round-trip). Closing the overlay leaves
// the component mounted but renders nothing — its internal
// `{#if preview.uid !== null}` guard collapses the DOM tree.
let PreviewOverlay = $state<Component | null>(null);
$effect(() => {
if (!browser) return;
if (preview.uid !== null && PreviewOverlay === null) {
void import('$lib/components/preview/PreviewOverlay.svelte').then((m) => {
PreviewOverlay = m.default as Component;
});
}
});
</script>
<svelte:head>
@@ -97,5 +115,7 @@
{:else}
{@render children?.()}
{/if}
<PreviewOverlay />
{#if PreviewOverlay}
<PreviewOverlay />
{/if}
</QueryClientProvider>