diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 0b114fb..1c487af 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -7,6 +7,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps'; import { isRawName, rawThumbnail } from './engine/rawDevelop'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { + cachedFolders, cachedPhotos, canBrowseFolders, clearLibraryCache, @@ -197,7 +198,7 @@ export function Library() { function Catalogue() { const { t } = useI18n(); - const [folders, setFolders] = useState([]); + const [folders, setFolders] = useState(() => cachedFolders()); const [ready, setReady] = useState>(new Set()); // The strip starts on the catalogue the last screen read: coming back from the // studio is a screen opening, not a page loading, and the frames the reader was @@ -2177,58 +2178,83 @@ function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick } // One object URL per tile, made when the row does and dropped with it: a folder -// of thousands cannot hold thousands of live URLs, and the list the panels paint is -// the only list to make them for. `also` is a frame drawn outside that list — the -// one on the stage, whose tile may be nowhere near the strip's window. +const globalThumbUrls = new Map(); +const GLOBAL_URL_MAX = 2500; + +function cacheObjectUrl(id: string, blob: Blob): string { + const existing = globalThumbUrls.get(id); + if (existing && existing.blob === blob) { + return existing.url; + } + if (existing) { + URL.revokeObjectURL(existing.url); + } else if (globalThumbUrls.size >= GLOBAL_URL_MAX) { + const oldestKey = globalThumbUrls.keys().next().value; + if (oldestKey) { + const item = globalThumbUrls.get(oldestKey); + if (item) URL.revokeObjectURL(item.url); + globalThumbUrls.delete(oldestKey); + } + } + const url = URL.createObjectURL(blob); + globalThumbUrls.set(id, { url, blob }); + return url; +} + function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record { - const [urls, setUrls] = useState>({}); - const made = useRef(new Map()); + const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]); + + const [urls, setUrls] = useState>(() => { + const initial: Record = {}; + for (const p of list) { + const cached = globalThumbUrls.get(p.id); + if (cached) initial[p.id] = cached.url; + } + return initial; + }); useEffect(() => { let alive = true; - const list = also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos; if (!list.length) { setUrls({}); return; } - const ids = list.map((p) => p.id); - void getPhotoThumbs(ids).then((thumbMap) => { + const syncUrls: Record = {}; + const idsToFetch: string[] = []; + for (const p of list) { + const cached = globalThumbUrls.get(p.id); + if (cached) { + syncUrls[p.id] = cached.url; + } else { + idsToFetch.push(p.id); + } + } + + if (Object.keys(syncUrls).length > 0) { + setUrls((prev) => ({ ...prev, ...syncUrls })); + } + + if (idsToFetch.length === 0) return; + + void getPhotoThumbs(idsToFetch).then((thumbMap) => { if (!alive) return; - const next: Record = {}; - for (const photo of list) { - const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; - if (!blob) continue; - const had = made.current.get(photo.id); - if (had && had.blob === blob) { - next[photo.id] = had.url; - } else { - if (had) URL.revokeObjectURL(had.url); - const url = URL.createObjectURL(blob); - made.current.set(photo.id, { url, blob }); - next[photo.id] = url; + setUrls((prev) => { + const next = { ...prev }; + for (const photo of list) { + const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; + if (blob) { + next[photo.id] = cacheObjectUrl(photo.id, blob); + } } - } - for (const [id, item] of made.current) { - if (id in next) continue; - URL.revokeObjectURL(item.url); - made.current.delete(id); - } - setUrls(next); + return next; + }); }); return () => { alive = false; }; - }, [photos, also]); - - useEffect( - () => () => { - for (const item of made.current.values()) URL.revokeObjectURL(item.url); - made.current.clear(); - }, - [] - ); + }, [list]); return urls; } diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index ecb23b0..71178cc 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -146,7 +146,7 @@ export function canBrowseFolders(): boolean { return typeof window !== 'undefined' && 'showDirectoryPicker' in window; } -const THUMB_CACHE_MAX = 500; +const THUMB_CACHE_MAX = 5000; const thumbCache = new Map(); export function cacheThumb(id: string, blob: Blob): void { @@ -278,14 +278,20 @@ function ask(store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore ); } -// --- folders --------------------------------------------------------------- +let cachedFoldersList: LibraryFolder[] | null = null; + +export function cachedFolders(): LibraryFolder[] { + return cachedFoldersList ?? []; +} export async function listFolders(): Promise { try { const rows = await ask(FOLDERS, 'readonly', (s) => s.getAll()); - return rows.sort((a, b) => a.name.localeCompare(b.name)); + const sorted = rows.sort((a, b) => a.name.localeCompare(b.name)); + cachedFoldersList = sorted; + return sorted; } catch { - return []; + return cachedFoldersList ?? []; } } diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index d51ea01..04a9c65 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -37,7 +37,6 @@ function pick(route: string) { ); } -// Intercept internal link clicks for instant SPA transitions without full page reload function Shell() { const [route, setRoute] = useState(() => window.location.pathname + window.location.search); @@ -64,7 +63,31 @@ function Shell() { }; }, []); - return pick(route); + const isApp = route.startsWith('/app'); + const isLibrary = route.startsWith('/library'); + const isOther = !isApp && !isLibrary; + + return ( + <> +
+ +
+
+ +
+ {isOther ? ( + route.startsWith('/admin') ? ( + + ) : route.startsWith('/photos') ? ( + + ) : route.startsWith('/profile') ? ( + + ) : ( + + ) + ) : null} + + ); } const root = document.getElementById('root');