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:
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -163,6 +165,114 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type folderCountsBody struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderCountsRow is the minimal PhotoPrism photo projection the handler
|
||||||
|
// needs — just UID, so dedupe-by-UID survives `merged=false` (which
|
||||||
|
// expands one photo into one row per File on disk). PhotoPrism returns a
|
||||||
|
// JSON array of much richer objects; unmarshalling into this small
|
||||||
|
// shape ignores everything we don't care about.
|
||||||
|
type folderCountsRow struct {
|
||||||
|
UID string `json:"UID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFolderCounts returns photo counts for each PhotoPrism folder
|
||||||
|
// path in one round-trip. The web client used to fire one
|
||||||
|
// `/photos?count=1000` per folder from the browser (≈1 MB JSON per
|
||||||
|
// folder × N folders) to populate the left-sidebar tree. Moving the
|
||||||
|
// fan-out into the sidecar keeps the same correctness profile — same
|
||||||
|
// q-DSL, same `merged=false` UID dedupe, same 1000-row server cap —
|
||||||
|
// but the wire payload back to the browser collapses to a single small
|
||||||
|
// JSON object (`{path: count}`).
|
||||||
|
//
|
||||||
|
// We bounce off PhotoPrism with `count=1000` and dedupe UIDs server-
|
||||||
|
// side rather than trusting a count header: PhotoPrism's `/photos`
|
||||||
|
// X-Count is the *per-page* row count (per existing front-end
|
||||||
|
// comment), not the total-match count, so we'd silently undercount if
|
||||||
|
// we used it. Lifting the 1000 cap would mean either iterating offsets
|
||||||
|
// or growing PhotoPrism's response cap — both out of scope here.
|
||||||
|
//
|
||||||
|
// Bounded concurrency caps the fan-out so a library with hundreds of
|
||||||
|
// folders doesn't open hundreds of connections to PhotoPrism at once.
|
||||||
|
// Errors per-folder degrade to count=0 rather than failing the whole
|
||||||
|
// batch — the sidebar would rather show a missing badge for one folder
|
||||||
|
// than nothing for any.
|
||||||
|
func handleFolderCounts(pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body folderCountsBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Paths) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxInFlight = 8
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, maxInFlight)
|
||||||
|
mu sync.Mutex
|
||||||
|
counts = make(map[string]int, len(body.Paths))
|
||||||
|
)
|
||||||
|
// Seed every input key so the response always carries the same
|
||||||
|
// shape the client posted, even for paths whose lookup failed.
|
||||||
|
for _, p := range body.Paths {
|
||||||
|
counts[p] = 0
|
||||||
|
}
|
||||||
|
for _, p := range body.Paths {
|
||||||
|
path := p
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
// `path:` is non-recursive in PhotoPrism's q-DSL: matches
|
||||||
|
// direct children only. `merged=false` returns one row per
|
||||||
|
// File on disk, so HEIC + companion JPG count twice unless
|
||||||
|
// we dedupe by UID — which is what the old client-side
|
||||||
|
// code did, and what we keep doing here.
|
||||||
|
q := url.QueryEscape("path:" + path)
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet,
|
||||||
|
"/api/v1/photos?count=1000&offset=0&merged=false&q="+q, token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
slog.Warn("folder.counts: pp call failed",
|
||||||
|
"path", path,
|
||||||
|
"err", err,
|
||||||
|
"status", func() int {
|
||||||
|
if resp != nil {
|
||||||
|
return resp.Status
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []folderCountsRow
|
||||||
|
if err := json.Unmarshal(resp.Body, &rows); err != nil {
|
||||||
|
slog.Warn("folder.counts: parse failed", "path", path, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.UID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r.UID] = struct{}{}
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
counts[path] = len(seen)
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
c.JSON(http.StatusOK, counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fireReindex wraps pp.reindex with logging and a detached context so
|
// fireReindex wraps pp.reindex with logging and a detached context so
|
||||||
// it can run in a goroutine after the response has gone out. The Node
|
// it can run in a goroutine after the response has gone out. The Node
|
||||||
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
// prototype kicks reindex with `void reindex(...)` and never awaits;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func main() {
|
|||||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||||
|
|
||||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||||
|
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||||
|
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ func newPPClient(base string) *ppClient {
|
|||||||
|
|
||||||
// ppResp is the trimmed projection of an HTTP response that callers
|
// ppResp is the trimmed projection of an HTTP response that callers
|
||||||
// actually consume. Status + raw body are exposed so handlers can mirror
|
// actually consume. Status + raw body are exposed so handlers can mirror
|
||||||
// PhotoPrism's status code or parse the body themselves.
|
// PhotoPrism's status code or parse the body themselves. Header is
|
||||||
|
// retained for callers that need `X-Count` / `X-Limit` / `X-Offset` on
|
||||||
|
// list endpoints — PhotoPrism exposes total-match counts there.
|
||||||
type ppResp struct {
|
type ppResp struct {
|
||||||
OK bool
|
OK bool
|
||||||
Status int
|
Status int
|
||||||
Body []byte
|
Body []byte
|
||||||
|
Header http.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
// call issues an authenticated request against PhotoPrism. body is
|
// call issues an authenticated request against PhotoPrism. body is
|
||||||
@@ -78,6 +81,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
|||||||
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
Status: resp.StatusCode,
|
Status: resp.StatusCode,
|
||||||
Body: buf,
|
Body: buf,
|
||||||
|
Header: resp.Header,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,24 +63,51 @@ export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
|
|||||||
let lastLast = -1;
|
let lastLast = -1;
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
|
|
||||||
function compute() {
|
// Between frames the visible band can shift by at most ~one viewport
|
||||||
rafId = null;
|
// of shells (any further and it's a programmatic jump, which falls
|
||||||
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
|
// back to a full sweep below). 300 covers a fast-flick on the densest
|
||||||
if (shells.length === 0) return;
|
// thumbnail preset (XS) plus a buffer; tightening it further saves
|
||||||
const rootRect = node.getBoundingClientRect();
|
// little and risks missing the new band after a quick scroll-wheel
|
||||||
// Sweep through shells (rendered in document order = photo order)
|
// flick.
|
||||||
// and find the first/last whose rect crosses the viewport. Bail
|
const SCAN_MARGIN = 300;
|
||||||
// out the moment we pass the bottom edge — shells past the
|
|
||||||
// viewport can't intersect, no point measuring them.
|
function scanFrom(
|
||||||
|
shells: NodeListOf<HTMLElement>,
|
||||||
|
rootRect: DOMRect,
|
||||||
|
start: number
|
||||||
|
): [number, number] {
|
||||||
let first = -1;
|
let first = -1;
|
||||||
let last = -1;
|
let last = -1;
|
||||||
for (let i = 0; i < shells.length; i++) {
|
for (let i = start; i < shells.length; i++) {
|
||||||
const r = shells[i].getBoundingClientRect();
|
const r = shells[i].getBoundingClientRect();
|
||||||
if (r.bottom < rootRect.top) continue;
|
if (r.bottom < rootRect.top) continue;
|
||||||
if (r.top > rootRect.bottom) break;
|
if (r.top > rootRect.bottom) break;
|
||||||
if (first === -1) first = i;
|
if (first === -1) first = i;
|
||||||
last = i;
|
last = i;
|
||||||
}
|
}
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute() {
|
||||||
|
rafId = null;
|
||||||
|
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
|
||||||
|
if (shells.length === 0) return;
|
||||||
|
const rootRect = node.getBoundingClientRect();
|
||||||
|
// Anchor the sweep around the previous result so a deep timeline
|
||||||
|
// doesn't pay `getBoundingClientRect()` × (every-shell-above-the-
|
||||||
|
// viewport) on every scroll tick. Previous loop scanned from 0
|
||||||
|
// each time → quadratic-feeling on long sessions with 1000+
|
||||||
|
// loaded photos.
|
||||||
|
const startHint = lastFirst >= 0 ? Math.max(0, lastFirst - SCAN_MARGIN) : 0;
|
||||||
|
let [first, last] = scanFrom(shells, rootRect, startHint);
|
||||||
|
// Bounded scan missed the band — user scrolled past the hint
|
||||||
|
// margin (programmatic jump, filter-reset reflow, etc.). Fall
|
||||||
|
// back to a single full sweep to re-anchor. Costs the same as
|
||||||
|
// the old behavior on this one frame, then bounded scans take
|
||||||
|
// over again.
|
||||||
|
if (first === -1 && startHint > 0) {
|
||||||
|
[first, last] = scanFrom(shells, rootRect, 0);
|
||||||
|
}
|
||||||
if (first === -1 || last === -1) return;
|
if (first === -1 || last === -1) return;
|
||||||
if (first === lastFirst && last === lastLast) return;
|
if (first === lastFirst && last === lastLast) return;
|
||||||
lastFirst = first;
|
lastFirst = first;
|
||||||
|
|||||||
@@ -127,16 +127,33 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||||
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
// FileCount: 0 for every folder, so the sidecar /folders/counts
|
||||||
// in parallel. Key the query off the folder-path list so it refetches
|
// endpoint resolves them in one round-trip (see listFolderCounts).
|
||||||
// when folders are added/renamed/deleted, and share the ['photos', …]
|
// Key the query off the folder-path list so it refetches when folders
|
||||||
// prefix so it invalidates alongside the other photo caches whenever a
|
// are added/renamed/deleted, and share the ['photos', …] prefix so it
|
||||||
// mutation lands.
|
// invalidates alongside the other photo caches whenever a mutation
|
||||||
|
// lands.
|
||||||
|
//
|
||||||
|
// `countsReady` gates the query until just after the sidebar's first
|
||||||
|
// paint. Even though the sidecar response is small, the per-folder
|
||||||
|
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
|
||||||
|
// blocking it on idle means the folder list paints immediately and
|
||||||
|
// the count badges fade in instead of holding back the whole tree.
|
||||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||||
|
let countsReady = $state(false);
|
||||||
|
if (browser) {
|
||||||
|
const kick = () => (countsReady = true);
|
||||||
|
// requestIdleCallback isn't in Safari yet; fall back to a short
|
||||||
|
// timeout so the deferral is still bounded.
|
||||||
|
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||||
|
.requestIdleCallback;
|
||||||
|
if (typeof ric === 'function') ric(kick);
|
||||||
|
else setTimeout(kick, 200);
|
||||||
|
}
|
||||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||||
queryFn: () => listFolderCounts(folderPaths),
|
queryFn: () => listFolderCounts(folderPaths),
|
||||||
enabled: isAuthenticated() && folderPaths.length > 0,
|
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
|
||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
}));
|
}));
|
||||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Maximize2 } from 'lucide-svelte';
|
import { Maximize2 } from 'lucide-svelte';
|
||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
import { thumbSrc, thumbSrcSet } from '$lib/stores/session.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,6 +30,14 @@
|
|||||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
||||||
|
|
||||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||||
|
// Render-size hint for the browser's srcset picker. `view.thumbnailSize`
|
||||||
|
// is the grid's `minmax(<px>, 1fr)` minimum — real tiles may be a hair
|
||||||
|
// wider when the grid stretches to fill the column, but `tile_*` is
|
||||||
|
// discrete (100/224/500) so the rounding-up at the next variant
|
||||||
|
// boundary swallows the difference.
|
||||||
|
const tilePx = $derived(view.thumbnailSize);
|
||||||
|
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||||
|
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -68,9 +77,11 @@
|
|||||||
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(hash, 'tile_500')}
|
src={src1x}
|
||||||
|
srcset={srcset}
|
||||||
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
class:transition={!selected}
|
class:transition={!selected}
|
||||||
class:group-hover:scale-105={!selected}
|
class:group-hover:scale-105={!selected}
|
||||||
|
|||||||
@@ -364,33 +364,32 @@ export async function getImportInfo(): Promise<ImportInfo> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||||
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
|
* endpoint reports `FileCount: 0` even when populated, so the count has
|
||||||
* response has no total-rows header — X-Count is the per-page row count.
|
* to be derived from a `/photos?q=path:X` lookup per folder.
|
||||||
* 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
|
* We hand this off to the sidecar (`POST /api/sidecar/folders/counts`)
|
||||||
* pair counts twice if we trusted `data.length`. Capped at the server's
|
* which fans out to PhotoPrism over loopback, dedupes by UID, and
|
||||||
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
|
* 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
|
* `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
|
* children only, so summing the per-path counts (no double-counting
|
||||||
* nested folders) is the right way to derive the root-folder photo
|
* from nested folders) is the right way to derive the root-folder
|
||||||
* count.
|
* 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
|
* Returns a plain object keyed by the input paths to keep it JSON-
|
||||||
* for TanStack's structural sharing.
|
* friendly for TanStack's structural sharing.
|
||||||
*/
|
*/
|
||||||
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
||||||
const entries = await Promise.all(
|
if (paths.length === 0) return {};
|
||||||
paths.map(async (path) => {
|
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
|
||||||
const { data } = await http.get<PpPhoto[]>('/photos', {
|
string,
|
||||||
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
|
number
|
||||||
});
|
>;
|
||||||
const uids = new Set<string>();
|
return data;
|
||||||
for (const p of data) uids.add(p.UID);
|
|
||||||
return [path, uids.size] as const;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Object.fromEntries(entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -88,6 +88,55 @@ export function thumbUrl(hash: string, size = 'tile_500'): string {
|
|||||||
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
|
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PhotoPrism's square-cropped tile sizes (px). These are the variants
|
||||||
|
* the indexer generates by default for the `tile_*` family. fit_* exists
|
||||||
|
* for non-square sizing but is the wrong fit for grid cells with
|
||||||
|
* `object-cover` — we always render a square.
|
||||||
|
*/
|
||||||
|
const TILE_SIZES = [100, 224, 500] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the smallest PhotoPrism tile variant whose pixel count is at or
|
||||||
|
* above the on-screen target. Falls through to the largest (500) for
|
||||||
|
* anything bigger — we don't have a tile_720+ variant. Used by both the
|
||||||
|
* 1x and 2x slots of the srcset helper below.
|
||||||
|
*/
|
||||||
|
function pickTileSize(targetPx: number): string {
|
||||||
|
for (const s of TILE_SIZES) {
|
||||||
|
if (s >= targetPx) return `tile_${s}`;
|
||||||
|
}
|
||||||
|
return `tile_${TILE_SIZES[TILE_SIZES.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a thumbnail `srcset` for a photo at a given on-screen tile
|
||||||
|
* size. The browser picks the right variant for the current device
|
||||||
|
* pixel ratio — on a 2x display we serve `tile_500` for a 272px XL
|
||||||
|
* tile, on a 1x display the same tile gets `tile_500` only if no
|
||||||
|
* smaller variant covers it, so most users save bandwidth.
|
||||||
|
*
|
||||||
|
* Returns the `srcset` value (no `src` attribute — pair with `thumbUrl`
|
||||||
|
* for the 1x fallback). Two variants is enough: PhotoPrism only
|
||||||
|
* indexes three square sizes (100/224/500), so 1x and 2x cover the
|
||||||
|
* realistic DPR range without flooding the cache.
|
||||||
|
*/
|
||||||
|
export function thumbSrcSet(hash: string, targetPx: number): string {
|
||||||
|
if (!session.previewToken) return '';
|
||||||
|
const one = pickTileSize(targetPx);
|
||||||
|
const two = pickTileSize(targetPx * 2);
|
||||||
|
const url1x = thumbUrl(hash, one);
|
||||||
|
const url2x = thumbUrl(hash, two);
|
||||||
|
if (url1x === url2x) return `${url1x} 1x`;
|
||||||
|
return `${url1x} 1x, ${url2x} 2x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Companion to `thumbSrcSet` — the `src` attribute value (1x). */
|
||||||
|
export function thumbSrc(hash: string, targetPx: number): string {
|
||||||
|
if (!session.previewToken) return '';
|
||||||
|
return thumbUrl(hash, pickTileSize(targetPx));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a video stream URL. PhotoPrism's endpoint is
|
* Build a video stream URL. PhotoPrism's endpoint is
|
||||||
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
|
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||||
import { resizable } from '$lib/actions/resizable';
|
import { resizable } from '$lib/actions/resizable';
|
||||||
import { queryClient } from '$lib/queryClient';
|
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 LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
|
|
||||||
@@ -42,6 +43,23 @@
|
|||||||
void goto('/login', { replaceState: true });
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -97,5 +115,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
<PreviewOverlay />
|
{#if PreviewOverlay}
|
||||||
|
<PreviewOverlay />
|
||||||
|
{/if}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -143,16 +143,20 @@
|
|||||||
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
|
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
|
||||||
* pages can repeat a photo when its file-row span straddles the offset
|
* pages can repeat a photo when its file-row span straddles the offset
|
||||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
* preserves order.
|
||||||
* handlers) treat this as the single source of truth.
|
*
|
||||||
|
* Split into two derived values so the heavy dedup pass only runs
|
||||||
|
* when the query's pages array changes (i.e. a fetch landed). The
|
||||||
|
* cheap folder-scope filter then runs whenever the user flips the
|
||||||
|
* filter store — search-as-you-type, section toggle, root vs.
|
||||||
|
* subfolder — without re-walking every page on each keystroke.
|
||||||
*
|
*
|
||||||
* When the user picks the root entry in the folder tree we filter
|
* When the user picks the root entry in the folder tree we filter
|
||||||
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||||||
* express that match, so the query fetches the whole library and
|
* express that match, so the query fetches the whole library and
|
||||||
* we strip subfolder rows post-hoc. */
|
* we strip subfolder rows post-hoc. */
|
||||||
const photos = $derived<PpPhoto[]>(
|
const dedupedAll = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
||||||
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
|
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
|
||||||
);
|
|
||||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||||
if (!pages) return [];
|
if (!pages) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -287,6 +291,15 @@
|
|||||||
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
|
Math.max(visLast, forcedExpand ?? visLast) + TILE_BUFFER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Hoist the selection reads out of the per-tile each-block. The same
|
||||||
|
// `has` / `===` semantics as `isSelected(uid) || selection.focused === uid`,
|
||||||
|
// just expressed at the loop level so the render reads as "given this
|
||||||
|
// (selected, focused) snapshot, here's each tile's state". No reactivity
|
||||||
|
// change — SvelteSet's `has` already subscribes per-key — but it removes
|
||||||
|
// the function-call indirection and keeps the hot loop body terse.
|
||||||
|
const selectedIds = $derived(selection.ids);
|
||||||
|
const focusedUid = $derived(selection.focused);
|
||||||
|
|
||||||
// Reset the window when the filter key changes — old indices from the
|
// Reset the window when the filter key changes — old indices from the
|
||||||
// previous photo list otherwise pin renderFirst/renderLast outside the
|
// previous photo list otherwise pin renderFirst/renderLast outside the
|
||||||
// new shorter list and the grid renders empty. Track `filtersToQ` as
|
// new shorter list and the grid renders empty. Track `filtersToQ` as
|
||||||
@@ -878,7 +891,7 @@
|
|||||||
use:tileRegister={i}
|
use:tileRegister={i}
|
||||||
>
|
>
|
||||||
{#if inWindow}
|
{#if inWindow}
|
||||||
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
|
{@const sel = selectedIds.has(photo.UID) || focusedUid === photo.UID}
|
||||||
<PhotoTile
|
<PhotoTile
|
||||||
{photo}
|
{photo}
|
||||||
selected={sel}
|
selected={sel}
|
||||||
|
|||||||
Reference in New Issue
Block a user