diff --git a/frontend/src/lib/components/file/FileCard.svelte b/frontend/src/lib/components/file/FileCard.svelte index a6decee..cd1dd1f 100644 --- a/frontend/src/lib/components/file/FileCard.svelte +++ b/frontend/src/lib/components/file/FileCard.svelte @@ -30,8 +30,32 @@ let imgSrc = $state(null); let failed = $state(false); + // Gate the fetch on visibility so a long grid doesn't fire every thumbnail + // request on mount; the tile loads once it scrolls near the viewport. + let visible = $state(false); + + // Svelte action: flips `visible` true the first time the card nears the + // viewport, then stops observing — the blob is kept once loaded. + function lazyload(node: HTMLElement) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + visible = true; + observer.disconnect(); + } + }, + { rootMargin: '200px' } + ); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + } + }; + } $effect(() => { + if (!visible) return; const token = get(authStore).accessToken; let objectUrl: string | null = null; let cancelled = false; @@ -112,6 +136,7 @@ class:loaded={!!imgSrc} class:selected class:focused + use:lazyload data-file-index={index} onpointerdown={onPointerDown} onpointermove={onPointerMoveInternal} diff --git a/frontend/src/lib/components/file/Thumb.svelte b/frontend/src/lib/components/file/Thumb.svelte index be70dba..ffba874 100644 --- a/frontend/src/lib/components/file/Thumb.svelte +++ b/frontend/src/lib/components/file/Thumb.svelte @@ -14,17 +14,43 @@ let imgSrc = $state(null); let failed = $state(false); + // Gate the fetch on visibility. A duplicate cluster can hold hundreds of files, + // and firing every thumbnail request on mount buries the server in a request + // storm (10k+ in-flight is easy). We only load once the tile nears the viewport. + let visible = $state(false); + + // Svelte action: flips `visible` true the first time the tile nears the + // viewport, then stops observing — the blob is kept once loaded. + function lazyload(node: HTMLElement) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + visible = true; + observer.disconnect(); + } + }, + { rootMargin: '200px' } + ); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + } + }; + } // Thumbnails are auth-gated, so fetch with the bearer token and render the blob - // (mirrors FileCard's loader). Re-runs whenever the id changes. + // (mirrors FileCard's loader). Runs once visible; re-runs whenever the id changes. $effect(() => { + if (!visible) return; const token = get(authStore).accessToken; + const currentId = id; // track id so a reused node refetches on change let objectUrl: string | null = null; let cancelled = false; imgSrc = null; failed = false; - fetch(`/api/v1/files/${id}/thumbnail`, { + fetch(`/api/v1/files/${currentId}/thumbnail`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }) .then((res) => (res.ok ? res.blob() : null)) @@ -47,7 +73,7 @@ }); -
+
{#if imgSrc} {:else if failed}