diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 2e32a29..2a402a6 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -936,6 +936,24 @@ export function Workspace() { void forgetPhoto(); }, [clearPreview]); + // Clean up Object URLs when App unmounts (e.g. SPA navigation to Library) + useEffect(() => { + return () => { + setPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCompareUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setSource((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return null; + }); + }; + }, []); + // What the phone app gets from the OS geocoder, the browser has no twin of, // so the name behind a coordinate comes from the API. Fire and forget: the // stamp is already readable without it, and a name that arrives late (or not diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index de91473..266aa39 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -667,7 +667,16 @@ export function Landing() { aria-label={t('nav.language')} onClick={() => setLang(lang === 'vi' ? 'en' : 'vi')} > - {lang.toUpperCase()} + {/* On a phone the button wears a globe; on a wide screen it wears + the code, where there is room to say which language is up. + Both live in the DOM — neither is the button's name (`aria-label` + above is), so which one is drawn is a stylesheet's business. */} + + {lang.toUpperCase()} {/* Sign in is the default: the dialog opens here, on the page the diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 7f129b4..a2dcec7 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4,11 +4,14 @@ import { go } from './nav'; import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; +import { isRawName, rawThumbnail } from './engine/rawDevelop'; import { cachedPhotos, canBrowseFolders, + clearLibraryCache, ensurePermission, getPhoto, + getPhotoThumbs, HEAD_BYTES, jumpScan, listEditedIds, @@ -71,6 +74,7 @@ const FRAME_KEY = 'recipescam.library.frame'; const COLUMN_KEY = 'recipescam.library.column'; const OPEN_KEY = 'recipescam.library.open'; const DEEP_KEY = 'recipescam.library.deep'; +const VIEW_KEY = 'recipescam.library.view'; const COLUMN_MIN = 88; const COLUMN_MAX = 460; const COLUMN_DEFAULT = 118; @@ -112,8 +116,17 @@ interface Shot { // back on a timer while a scan runs, which hands this screen a new frame object // every few hundred milliseconds, so what keeps the file from being read again is // the read in flight rather than the object under it. +const SHOTS_MAX = 50; const shots = new Map>(); +function keepShot(id: string, promise: Promise): void { + if (shots.size >= SHOTS_MAX) { + const oldestKey = shots.keys().next().value; + if (oldestKey) shots.delete(oldestKey); + } + shots.set(id, promise); +} + // How often a running scan is allowed to have its catalogue read back, and how // often the tab may walk the folder again just for being raised. Both are the // same trade — the reading is watched, not driven — and both are seconds for the @@ -162,15 +175,15 @@ export function Library() { // tree the way the last one was left. const [expanded, setExpanded] = useState>(() => { const saved = recall(OPEN_KEY); - // Nothing remembered means this is the first visit under that rule, and the - // node the last visit left would then open behind a shut row: its own rows - // are opened for it. A visit that folded everything remembers the empty set, - // which is not this, and is drawn as it was left. + const nodeParts = (recall(NODE_KEY) ?? '').split('/'); + const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/')); + if (saved === null) { - const parts = (recall(NODE_KEY) ?? '').split('/'); - return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/'))); + return new Set(nodeParents); } - return new Set(saved.split(',').filter(Boolean)); + const set = new Set(saved.split(',').filter(Boolean)); + for (const parent of nodeParents) set.add(parent); + return set; }); // Whether the strip draws the whole branch under the open node or only the // node itself. Deep is how a roll reads — its top level holds no file of its @@ -183,7 +196,7 @@ export function Library() { // first one the node happens to hold. A frame that is gone falls through to // the first, the way a node that is gone falls back to its folder. const [up, setUp] = useState(() => recall(FRAME_KEY)); - const [view, setView] = useState<'preview' | 'grid'>('preview'); + const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview')); // How the thumbnail view reads the open folder: every frame it holds, or only // the ones that were given this many stars, shot in this year, shot between // these hours — and in the order the reader picked. A way of looking, not part @@ -472,25 +485,37 @@ export function Library() { }; }, [reload]); + // The catalogue outlives the tab; the permission to read those folders does + // not. Asking is a quiet prompt, and a refusal leaves the folder listed with + // a button that asks again on a click. // The catalogue outlives the tab; the permission to read those folders does // not. Asking is a quiet prompt, and a refusal leaves the folder listed with // a button that asks again on a click. useEffect(() => { let alive = true; void (async () => { + // 1. Trigger catalog reload immediately in parallel + const reloadPromise = reload(); + + // 2. Fetch list of directory folders const rows = await listFolders(); if (!alive) return; setFolders(rows); - // The remembered folder only stands while the folder it belongs to does: a - // folder that was removed is not a folder to open on. setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null))); - const ok = new Set(); - for (const folder of rows) { - if (await ensurePermission(folder.handle)) ok.add(folder.name); - } + + // 3. Query directory permissions in parallel + const permResults = await Promise.all( + rows.map(async (folder) => ({ + name: folder.name, + ok: await ensurePermission(folder.handle), + })) + ); if (!alive) return; + const ok = new Set(permResults.filter((r) => r.ok).map((r) => r.name)); setReady(ok); - await reload(); + + // 4. Await photos reload if still running + await reloadPromise; if (!alive) return; setLoaded(true); })(); @@ -517,6 +542,17 @@ export function Library() { useEffect(() => { keep(DEEP_KEY, String(deep)); }, [deep]); + useEffect(() => { + keep(VIEW_KEY, view); + }, [view]); + + // Clear global memory caches when leaving Library screen + useEffect(() => { + return () => { + shots.clear(); + clearLibraryCache(); + }; + }, []); // The menu closes on the next click anywhere but its own items, and on Escape. // Escape is the whole reason this listens on the document instead of sitting @@ -710,17 +746,11 @@ export function Library() { // numbers drawn over the column would count a roll down to a branch of itself. // The column falls back on the catalogue until the reading is through. const read = live && !live.progress.from ? live.progress.counts : null; - const nodes = useMemo(() => { + // Memoize photo count map derived from photos and dirs array separately so that + // live scan progress ticks (every 200ms) do not re-scan tens of thousands of photos. + const baseTreeInfo = useMemo(() => { const hits = new Map(); - // The reading's own count is a floor, never a ceiling: a row says at least - // what the catalogue already holds of it. A roll being read again says its - // own size while the reading is still walking up to it — where the reading's - // number alone would have the head of the tree count 29980 while the line - // above it counts a hundred thousand, one question with two answers. - const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0); const foldersUnder = new Map>(); - // One path per folder between the picked one and `rel`, so a row is drawn for - // every folder on the way to a frame as well as for the frame's own. const add = (folder: string, rel: string, counted: boolean) => { const set = foldersUnder.get(folder) ?? new Set(); foldersUnder.set(folder, set); @@ -735,9 +765,13 @@ export function Library() { hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1); if (photo.dir) add(photo.folder, photo.dir, true); } - // A folder the walk found is a row before it holds a frame: the names arrive - // a pass ahead of the thumbnails. for (const dir of dirs) add(dir.folder, dir.rel, false); + return { hits, foldersUnder }; + }, [photos, dirs]); + + const nodes = useMemo(() => { + const { hits, foldersUnder } = baseTreeInfo; + const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0); const out: Node[] = []; for (const folder of folders) { out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) }); @@ -754,7 +788,7 @@ export function Library() { } } return out; - }, [dirs, folders, photos, read]); + }, [baseTreeInfo, folders, read]); // A row with anything under it is the one that folds: its key is the prefix of // its children's, in the path order the rows come in. @@ -840,6 +874,53 @@ export function Library() { const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)); const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame); + // Auto-scroll & focus tree sidebar to the active node selected in last session + const treeRef = useRef(null); + const scrolledNodeRef = useRef(null); + + useEffect(() => { + if (!node || !loaded) return; + if (scrolledNodeRef.current === node) return; + + const scrollToNode = () => { + const treeEl = treeRef.current; + if (!treeEl) return; + const activeBtn = treeEl.querySelector('.adm-album.on'); + if (activeBtn) { + activeBtn.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + scrolledNodeRef.current = node; + } + }; + + scrollToNode(); + const timer = setTimeout(scrollToNode, 120); + return () => clearTimeout(timer); + }, [node, loaded, rows]); + + // Auto-scroll timeline filmstrip to center the active selected thumbnail + const scrolledFrameRef = useRef(null); + + useEffect(() => { + if (view !== 'preview' || !frame || !shown.length) return; + const idx = shown.findIndex((p) => p.id === frame.id); + if (idx < 0) return; + + const scrollStripToFrame = () => { + const el = strip.ref.current; + if (!el) return; + const width = el.clientWidth || 800; + const targetLeft = Math.max(0, idx * TILE_STEP - width / 2 + TILE_STEP / 2); + el.scrollLeft = targetLeft; + }; + + const key = `${node}:${frame.id}`; + if (scrolledFrameRef.current !== key) { + scrolledFrameRef.current = key; + scrollStripToFrame(); + requestAnimationFrame(scrollStripToFrame); + } + }, [view, frame?.id, shown, node, strip.ref]); + // A frame's score: click a star to give it, click the star it already has to // take the score back. The catalogue holds it, so it is the same score the next // visit reads — and the list on screen is told, rather than read back whole for @@ -886,7 +967,7 @@ export function Library() { return null; } })(); - shots.set(id, read); + keepShot(id, read); } void read.then((shot) => { if (live) setShot(shot); @@ -902,6 +983,59 @@ export function Library() { setZoom(1); }, [frame?.id]); + const [stageUrl, setStageUrl] = useState(null); + const stageUrlRef = useRef(null); + + const revokeStageUrl = () => { + if (stageUrlRef.current) { + URL.revokeObjectURL(stageUrlRef.current); + stageUrlRef.current = null; + } + }; + + // Load high-resolution photo/preview for the raised stage frame directly from local file handle + useEffect(() => { + revokeStageUrl(); + setStageUrl(null); + if (!frame) return; + + let live = true; + void (async () => { + try { + const file = await readPhotoFile(frame); + if (!live) return; + + let url: string | null = null; + if (isRawName(file.name)) { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (!live) return; + const previewBytes = await rawThumbnail(bytes); + if (previewBytes && live) { + const blob = new Blob([previewBytes as BlobPart], { type: 'image/jpeg' }); + url = URL.createObjectURL(blob); + } + } else { + url = URL.createObjectURL(file); + } + + if (live && url) { + revokeStageUrl(); + stageUrlRef.current = url; + setStageUrl(url); + } else if (url) { + URL.revokeObjectURL(url); + } + } catch { + // Fall back to thumb tile URL if file reading fails + } + })(); + + return () => { + live = false; + revokeStageUrl(); + }; + }, [frame?.id]); + // A wheel tick over the frame is the zoom — a negative is the one thing on this // screen worth filling the window with — and the point under the pointer is the // point that stays put, which is how a reader aims at what to magnify. The @@ -1100,6 +1234,7 @@ export function Library() { column is the folder control, so one more folder is added where folders are. */}