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

@@ -364,33 +364,32 @@ export async function getImportInfo(): Promise<ImportInfo> {
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
* response has no total-rows header — X-Count is the per-page row count.
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
* pair counts twice if we trusted `data.length`. Capped at the server's
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
* endpoint reports `FileCount: 0` even when populated, so the count has
* to be derived from a `/photos?q=path:X` lookup per folder.
*
* We hand this off to the sidecar (`POST /api/sidecar/folders/counts`)
* which fans out to PhotoPrism over loopback, dedupes by UID, and
* returns a single `{path: count}` payload of <5 KB. The previous
* client-side implementation issued one `/photos?count=1000` per folder
* from the browser — on a library with 30 folders that's ≈30 MB of JSON
* pulled across the wire on every cold sidebar mount.
*
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
* children only, so summing the per-path counts (no double-counting from
* nested folders) is the right way to derive the root-folder photo
* count.
* children only, so summing the per-path counts (no double-counting
* from nested folders) is the right way to derive the root-folder
* photo count. Capped at PhotoPrism's 1000-row ceiling; folders larger
* than that under-report (pre-existing limitation, unchanged here).
*
* Returns a plain object keyed by the input paths to keep it JSON-friendly
* for TanStack's structural sharing.
* Returns a plain object keyed by the input paths to keep it JSON-
* friendly for TanStack's structural sharing.
*/
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
const entries = await Promise.all(
paths.map(async (path) => {
const { data } = await http.get<PpPhoto[]>('/photos', {
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
});
const uids = new Set<string>();
for (const p of data) uids.add(p.UID);
return [path, uids.size] as const;
})
);
return Object.fromEntries(entries);
if (paths.length === 0) return {};
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
string,
number
>;
return data;
}
// ── Geo ──────────────────────────────────────────────────────────────────────