import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react'; import { useI18n } from './i18n/I18nProvider'; import { go } from './nav'; import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; import { canBrowseFolders, ensurePermission, getPhoto, HEAD_BYTES, jumpScan, listEditedIds, listFolders, listPhotos, pickFolder, readPhotoFile, removeFolder, renameFolder, scanSession, setStar, startScan, stopScan, watchScan, type LibraryDir, type LibraryFolder, type LibraryPhoto, } from './engine/library'; // The catalogue screen, laid out like the admin's picture manager: the tree of // folders and subfolders down the left, the frame that is up in the middle, and // the open folder's strip of thumbnails across the bottom. The toolbar holds the // one button that adds a folder and the two icons that swap the middle for every // thumbnail of the open folder at once; a folder's own rescan and remove live on // a right click. Nothing is uploaded — the page never holds more than // thumbnails, and the RAW itself is read only when a frame is opened in the // studio (`/app?lib=`). // // ponytail: no search and no flag. A roll of date folders is three levels deep at // most, and what a reader actually digs through a shelf with is the score they // gave a frame, the year it was shot and the hours they shot it in — which are // the three the thumbnail view filters on. Add the text box when a folder of // mixed names makes one worth typing into. interface Node { // The tree's own key: a folder name, or `folder/sub/dir`. key: string; label: string; depth: number; count: number; } // What this screen remembers between visits: the folder that was open, the frame // that was raised in it, how much room its column was given, and which folders of // the tree were drawn open. const NODE_KEY = 'recipescam.library.node'; const FRAME_KEY = 'recipescam.library.frame'; const COLUMN_KEY = 'recipescam.library.column'; const OPEN_KEY = 'recipescam.library.open'; const COLUMN_MIN = 88; const COLUMN_MAX = 460; const COLUMN_DEFAULT = 118; // How far a wheel tick over the frame magnifies it, and where it stops: past a // handful of ticks the thumbnail behind the preview runs out of pixels anyway. const ZOOM_MAX = 6; const ZOOM_STEP = 1.15; // The hours a frame's own clock can read, and what the time filter offers: the // whole day in the 24-hour form a camera writes into EXIF. const HOURS = Array.from({ length: 24 }, (_, i) => i); // What the frame's own file says about itself: the line of numbers the camera // recorded, and where it was shot. Both sit in the EXIF at the head of the file, // so the frame costs one read of its first few hundred kilobytes rather than the // whole negative — and only the raised frame ever pays it. interface Shot { line: string; lat: number | null; lng: number | null; // The geocoder's name for those coordinates, or null: naming a place is a // network round trip on a PRO account, and a refusal leaves the numbers. place: string | null; } // The frames already read this visit, by the read itself: the catalogue is read // 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 = new Map>(); export function Library() { const { t } = useI18n(); const [folders, setFolders] = useState([]); const [ready, setReady] = useState>(new Set()); const [photos, setPhotos] = useState([]); // The folders the scan in flight has walked into: a roll's own shape is on // screen from the first pass, before the frames under its folders are read. // What a scan found is not filed away — the next visit draws the folders that // hold frames, and the next scan names the rest again. const [dirs, setDirs] = useState([]); const [edited, setEdited] = useState>(new Set()); // Which tree node is open, which frame is up, and which of the two middle // views (the frame, or every thumbnail of the open node) is on. The open node // is picked up where the last visit left it. const [node, setNode] = useState(() => recall(NODE_KEY)); // The rows whose children are drawn. The column opens with every folder shut — // a roll is a shelf of shelves and its whole shape is not what the screen is // for — and remembers the ones the reader opened, so the next visit draws the // 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. 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(saved.split(',').filter(Boolean)); }); // 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 // own — and shallow is what a folder of folders is for. const [deep, setDeep] = useState(true); // The frame that is up in the open node — raised by a click on its thumbnail, // and picked up where the last visit left it, so a reader who leaves for the // studio comes back to the negative they were looking at rather than to the // 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'); // 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 // of the roll, so none of it outlives the visit; the strip below the frame does // not follow it, because the strip is the shelf itself. const [order, setOrder] = useState<'newest' | 'oldest' | 'star'>('newest'); const [minStar, setMinStar] = useState(0); const [year, setYear] = useState('all'); const [from, setFrom] = useState('any'); const [to, setTo] = useState('any'); // How far the frame is magnified, and the point it is magnified about: the one // under the pointer, so the picture grows around what is being looked at. const [zoom, setZoom] = useState(1); const [zoomAt, setZoomAt] = useState('50% 50%'); const [colWidth, setColWidth] = useState(() => { const saved = Number(recall(COLUMN_KEY)); return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT; }); // The scan is the tab's, not this screen's — see `scanSession`. This screen is // one of its watchers, and the counter is what tells React the session it is // holding has moved on. const live = scanSession(); const [, bump] = useReducer((n: number) => n + 1, 0); // When the catalogue was last read back while a scan runs: the refresh reads the // whole catalogue, so it keeps to one every few hundred milliseconds rather than // one per frame. const readAt = useRef(0); const [note, setNote] = useState(null); // The folder menu: what a right click opens, and where. No folder means the // right click landed on the empty part of the column, which is the way in for // one more folder; `root` means it landed on a row at the top level — the head // of a tree — which also carries folding the whole tree. const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; x: number; y: number } | null>(null); // Where the drag of the column divider started, and how wide the column was // then — the pointer only ever reports a position, not a distance moved. const grip = useRef<{ x: number; w: number } | null>(null); // The catalogue is loaded once at start-up; until it is, the tree only knows // the folders and cannot tell whether the remembered subfolder still exists. const [loaded, setLoaded] = useState(false); const reload = useCallback(async () => { const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); setPhotos(rows); setEdited(edits); }, []); // A scan runs for as long as the roll takes, and this screen watches it rather // than running it: the studio may be up instead, and the scan carries on. The // names a pass has walked into are drawn as they arrive, a pass ahead of the // frames under them; the catalogue behind the strip is read back on a timer. useEffect(() => { const watching = () => { bump(); const progress = scanSession()?.progress; if (!progress) return; setDirs(progress.dirs); if (progress.done === 0 || Date.now() - readAt.current > 700) { readAt.current = Date.now(); void reload(); } }; const unwatch = watchScan(watching); // A scan already in flight when this screen opens is picked up where it is: // the reader who left for the studio comes back to the same roll. watching(); return unwatch; }, [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. useEffect(() => { let alive = true; void (async () => { 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); } if (!alive) return; setReady(ok); await reload(); if (!alive) return; setLoaded(true); })(); return () => { alive = false; }; }, [reload]); // What the screen remembers: the folder that was open, the frame that was up in // it, and the width its column was dragged to. useEffect(() => { if (node) keep(NODE_KEY, node); }, [node]); useEffect(() => { if (up) keep(FRAME_KEY, up); }, [up]); useEffect(() => { keep(COLUMN_KEY, String(colWidth)); }, [colWidth]); useEffect(() => { keep(OPEN_KEY, [...expanded].join(',')); }, [expanded]); // 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 // behind a full-screen scrim: a scrim would eat the click that opens the next // folder, which is half of how this screen is used. useEffect(() => { if (!menu) return; const away = (e: Event) => { if (e.target instanceof Element && e.target.closest('.lib-menu')) return; setMenu(null); }; const key = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null); }; document.addEventListener('pointerdown', away); document.addEventListener('keydown', key); return () => { document.removeEventListener('pointerdown', away); document.removeEventListener('keydown', key); }; }, [menu]); const scan = useCallback( async (folder: LibraryFolder, quiet = false) => { setNote(null); try { const result = await startScan(folder); if (!quiet) setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); setDirs(result.dirs); await reload(); } catch { setNote(t('lib.failed')); } }, [reload, t] ); // The folder this screen is on is read back to the disk on the way in, and again // every time the tab is raised. A frame that has not moved costs a name, a size // and a write time and nothing else, so the walk is cheap — and cheap for the // two things the reader keeps running into: a roll whose first reading ended // when the app did (nothing of it was lost, and nothing finished it either), and // frames that landed in the folder since. No note: the ring on the row and the // progress line are the report. const rootKey = node ? node.split('/')[0] : null; useEffect(() => { const root = folders.find((f) => f.name === rootKey); if (!loaded || !root || !ready.has(root.name)) return; const catchUp = () => { if (!scanSession()) void scan(root, true); }; catchUp(); const raised = () => { if (document.visibilityState === 'visible') catchUp(); }; document.addEventListener('visibilitychange', raised); return () => document.removeEventListener('visibilitychange', raised); // `folders` and `ready` are read here, not watched: the two only ever gain a // name or a permission, and neither is a reason to read the disk again. }, [loaded, rootKey, scan]); const add = useCallback(async () => { setNote(null); try { const folder = await pickFolder(); if (!folder) return; setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name))); setReady((prev) => new Set(prev).add(folder.name)); setNode(folder.name); await scan(folder); } catch (err) { // Closing the picker is a "not now", not a failure worth a line of text. if (err instanceof DOMException && err.name === 'AbortError') return; setNote(t('lib.failed')); } }, [scan, t]); const reconnect = useCallback( async (folder: LibraryFolder) => { if (await ensurePermission(folder.handle)) { setReady((prev) => new Set(prev).add(folder.name)); await scan(folder); } else { setNote(t('lib.failed')); } }, [scan, t] ); // A rename only paints a label over the folder — the directory it points at is // not touched, which is the whole point of keeping the two apart. const rename = useCallback( async (folder: LibraryFolder) => { // The browser's own box: one line, no dialog to build, and it already knows // how to ask. ponytail: swap it for an inline field if a label ever needs // validating or previewing. const label = window.prompt(t('lib.renameAsk'), folder.label ?? folder.name)?.trim(); if (!label || label === (folder.label ?? folder.name)) return; await renameFolder(folder, label); setFolders((prev) => prev.map((f) => (f.name === folder.name ? { ...f, label } : f))); }, [t] ); const drop = useCallback( async (folder: LibraryFolder) => { await removeFolder(folder.name); const rest = folders.filter((f) => f.name !== folder.name); setFolders(rest); setDirs((prev) => prev.filter((d) => d.folder !== folder.name)); setNode(rest[0]?.name ?? null); await reload(); }, [folders, reload] ); // The frame goes to the studio through the address bar: the studio owns the // load (develop, EXIF, session) and this page owns no part of it. While a scan // is running the address bar is the one thing that changes — `go` keeps the // reading where it is instead of reloading the page out from under it. const open = useCallback(async (photo: LibraryPhoto) => { const row = await getPhoto(photo.id); if (!row) return; // Read once here so a moved or deleted file answers with a note instead of // a studio that opens on nothing. try { await readPhotoFile(row); } catch { setNote(t('lib.missing', { name: row.name })); return; } go(`/app?lib=${encodeURIComponent(photo.id)}`); }, [t]); const rootName = node ? node.split('/')[0] : null; // A row's folder as the walk spells it: '' for the picked folder itself, the // path under it for a subfolder, and nothing for a row belonging to another // folder — that one is not a folder of this roll. const relOf = (key: string): string | null => !rootName ? null : key === rootName ? '' : key.startsWith(`${rootName}/`) ? `${key.slice(rootName.length + 1)}/` : null; // The tree: one row per picked folder, then one per subfolder, indented by its // depth — the folders the walk has named, whether or not a frame has been read // out of them, each carrying how many frames sit at or under it. While a scan is // running the count is the scan's own, so a row says how far the reading has // come; the frames only reach the catalogue in one batch at the end, and a row // that stood still until then would say nothing at all. const read = live?.progress.counts ?? null; const nodes = useMemo(() => { const hits = new Map(); const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 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); let path = ''; for (const part of rel.split('/')) { path = path ? `${path}/${part}` : part; set.add(path); if (counted) hits.set(`${folder}/${path}`, (hits.get(`${folder}/${path}`) ?? 0) + 1); } }; for (const photo of photos) { 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); 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)) }); // Path order puts a folder before its own children, which is the order the // indented rows have to come in. for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) { const parts = dir.split('/'); out.push({ key: `${folder.name}/${dir}`, label: parts[parts.length - 1], depth: parts.length, count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)), }); } } return out; }, [dirs, folders, photos, 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. const parents = useMemo( () => new Set(nodes.filter((n) => n.depth > 0).map((n) => n.key.slice(0, n.key.lastIndexOf('/')))), [nodes] ); // What the column draws: a row whose parent is not open goes with it. const rows = useMemo( () => nodes.filter((n) => { const parts = n.key.split('/'); for (let i = 1; i < parts.length; i++) if (!expanded.has(parts.slice(0, i).join('/'))) return false; return true; }), [nodes, expanded] ); // A remembered subfolder that no longer holds a frame falls back to the folder // it sits in, so the screen never opens on a node that is not drawn. useEffect(() => { if (!loaded || !node || nodes.some((n) => n.key === node)) return; setNode(nodes.find((n) => n.key === node.split('/')[0])?.key ?? nodes[0]?.key ?? null); }, [loaded, node, nodes]); // What the open node draws: its own frames, and — unless the branch has been // shut out of the strip — the ones in every subfolder under it, because a // roll's top level usually holds no file of its own. const shown = useMemo(() => { if (!rootName) return photos; const dir = node === rootName ? '' : (node ?? '').slice(rootName.length + 1); const below = dir === '' ? (p: LibraryPhoto) => p.dir !== '' : (p: LibraryPhoto) => p.dir.startsWith(`${dir}/`); return photos.filter((p) => p.folder === rootName && (p.dir === dir || (deep && below(p)))); }, [deep, node, photos, rootName]); const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null; const urls = useThumbUrls(shown); // The years the open folder holds, newest first: the filter offers the years // there are frames to find in rather than a century of empty ones. const years = useMemo( () => [...new Set(photos.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a), [photos] ); // What the thumbnail view draws, out of what the open node holds. A frame's // hour is the hour the shutter fired in, on this machine's clock — the same // reading the frame's own date line prints, so 16:00–18:00 is the afternoon // the reader was out and not an offset of it. const listed = useMemo(() => { const rows = shown.filter((p) => { if (minStar && (p.star ?? 0) < minStar) return false; const at = new Date(p.taken); if (year !== 'all' && at.getFullYear() !== Number(year)) return false; if (from !== 'any' && at.getHours() < Number(from)) return false; if (to !== 'any' && at.getHours() > Number(to)) return false; return true; }); // `shown` is already newest shutter first, so the oldest is the same list // read backwards; the score comes first in the third, and the shutter breaks // its ties the way the shelf does. if (order === 'star') return [...rows].sort((a, b) => (b.star ?? 0) - (a.star ?? 0) || b.taken - a.taken); return order === 'oldest' ? [...rows].reverse() : rows; }, [shown, minStar, year, from, to, order]); // 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 // one number. const rate = useCallback(async (photo: LibraryPhoto, n: number) => { await setStar(photo.id, n); setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star: n } : p))); }, []); const [shot, setShot] = useState(null); // Where the frame was shot, as the coordinates it carries — the fallback for a // file whose place the geocoder could not name. const coords = shot && shot.lat !== null && shot.lng !== null ? `${shot.lat.toFixed(4)}, ${shot.lng.toFixed(4)}` : null; // The two lines the stage prints under the frame. The numbers come off the // file's own head, so what is on screen is what the camera wrote and nothing // this page invented; the place name is a network round trip that lands after // them, and one that is refused leaves the coordinates standing. useEffect(() => { if (!frame) return; const id = frame.id; let live = true; let read = shots.get(id); if (!read) { read = (async () => { try { const head = new Uint8Array(await (await readPhotoFile(frame)).slice(0, HEAD_BYTES).arrayBuffer()); const [specs, gps] = await Promise.all([readSpecs(head), readGps(head)]); const shot: Shot = { line: specsLine(specs), lat: gps?.latitude ?? null, lng: gps?.longitude ?? null, place: null }; if (shot.lat !== null && shot.lng !== null) { // Naming a place is a round trip on a PRO account; the numbers do not // wait for it, and a refusal or a miss simply leaves them alone. void api .place(shot.lat, shot.lng) .then(({ place }) => { if (!place) return; shot.place = place; if (live) setShot({ ...shot }); }) .catch(() => {}); } return shot; } catch { // A file that cannot be read says nothing about itself. return null; } })(); shots.set(id, read); } void read.then((shot) => { if (live) setShot(shot); }); return () => { live = false; }; }, [frame?.id]); // A frame that has just come up is fitted: the zoom belonged to the negative // that was up before it. useEffect(() => { setZoom(1); }, [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 // listener goes on the element rather than through `onWheel`, because React's // own wheel is passive and this one has to hold the page still while it zooms. // It is not taken off again: it lives on the element, and the element leaves // with it (the guard keeps a re-attach off the same node, which is what a // double-invoked render would otherwise do). const frameRef = useCallback((el: HTMLImageElement | null) => { if (!el || el.dataset.zoom === 'bound') return; el.dataset.zoom = 'bound'; el.addEventListener( 'wheel', (e) => { // A tick with no vertical axis (a wheel pushed sideways) is not a zoom. if (!e.deltaY) return; e.preventDefault(); const box = el.getBoundingClientRect(); // A tick is a notch, and a trackpad sends its notches in small pieces: the // step is taken from how far the wheel turned rather than assumed, so both // turn into the same travel under the pointer. const notches = Math.max(-3, Math.min(3, -e.deltaY / 100)); setZoomAt(`${(((e.clientX - box.left) / box.width) * 100).toFixed(2)}% ${(((e.clientY - box.top) / box.height) * 100).toFixed(2)}%`); setZoom((z) => Math.min(ZOOM_MAX, Math.max(1, z * ZOOM_STEP ** notches))); }, { passive: false } ); }, []); // The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose // preview the camera never wrote. A tile only carries a handler where a click // on the picture means "open it" — in the strip the click belongs to the frame // it raises, not to the studio. const tile = (photo: LibraryPhoto, onPick?: () => void) => urls[photo.id] ? ( ) : (
RAW
); return (
RecipesCam {t('lib.title')} {t('lib.count', { n: photos.length })} {t('nav.studio')}
{note ?

{note}

: null} {canBrowseFolders() ? (
{/* One toolbar for the whole screen: add, the scan in flight, and the two ways to look at the open folder. The hint rides along on the same row and ellipsises — the catalogue is the content, this bar is furniture. */}
{live ? ( ) : null} {t('lib.hint')} {live ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) : ''} {folders.length > 0 ? ( <> {/* Whether the strip draws the branch under the open folder or only the folder itself. The label says what the click does, `on` says where the strip is now. */} ) : null}
{folders.length === 0 ?

{t('lib.noFolders')}

: null} {folders.length > 0 ? (
{/* The shelf itself answers a right click on its empty part: the column is the folder control, so one more folder is added where folders are. */}
{ e.currentTarget.setPointerCapture(e.pointerId); grip.current = { x: e.clientX, w: colWidth }; }} onPointerMove={(e) => { const held = grip.current; if (!held) return; // 88px is the narrowest a name and its count still read at, // 460 the widest the stage can lose and stay a stage. setColWidth(Math.round(Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, held.w + e.clientX - held.x)))); }} onPointerUp={(e) => { grip.current = null; e.currentTarget.releasePointerCapture(e.pointerId); }} onKeyDown={(e) => { const step = e.key === 'ArrowLeft' ? -12 : e.key === 'ArrowRight' ? 12 : 0; if (!step) return; e.preventDefault(); setColWidth((w) => Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, w + step))); }} />
{view === 'grid' ? ( <> {/* What the shelf is read through. It holds every frame the open node does — a filter is a way of looking, not a second shelf — and the line at its end says how many are left, so a filter that finds nothing says so. */}
{/* The two ends of the hours: 16:00 to 18:00 is the afternoon the reader was out, and either end on its own is "from 16:00" or "up to 18:00". */} {t('lib.count', { n: listed.length })}
{listed.map((photo) => (
{tile(photo, () => void open(photo))}
{photo.name} {photo.star ? `★${photo.star} · ` : ''} {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
))}
) : frame ? ( <> {urls[frame.id] ? ( void open(frame)} title={t('lib.open')} style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }} /> ) : (
void open(frame)} title={t('lib.open')}> RAW
)}
{frame.name} {frame.dir || node} {new Date(frame.taken).toLocaleString()} · {t('lib.size', { mb: (frame.size / 1048576).toFixed(1) })} {edited.has(frame.id) ? ` · ${t('lib.edited')}` : ''} {shot?.place || coords ? ( {shot?.place ?? coords} ) : null} {shot?.line ? {shot.line} : null}
{/* The frame's own score, and the only place one is given: the star that is lit is the score it has, and the star under the click is the score it gets — the one it already has takes the score back. */}
{[1, 2, 3, 4, 5].map((n) => ( ))}
) : (

{t('lib.empty')}

)}
{/* The strip: one thumbnail per frame the open node holds, in the order the catalogue reads them (newest shutter first). */} {view === 'preview' ? (
{ if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY; }} > {shown.map((photo) => ( ))} {shown.length === 0 ?

{t('lib.empty')}

: null}
) : null}
) : null}
) : (

{t('lib.unsupported')}

)} {/* The context menu: a folder gets what belongs to a folder rather than to a frame, the empty part of the column gets the folder that is missing, and the head of the tree gets the whole tree folded. A folder whose permission has lapsed gets the one thing that can be done about it. */} {menu ? (
e.preventDefault()}> {/* Fold-the-tree leads the menu of a row at the top level, and is the way back to the column's rest: every row shut, the top-level one included, which is the shape the column opens in. A row's own click still draws its subtree and folds it again, so this is for a tree that was walked into rather than for one row. */} {menu.root ? ( ) : null} {menu.folder ? ( ready.has(menu.folder.name) ? ( <> ) : ( ) ) : ( )}
) : null}
); } // 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 `shown` is the only list // the panels paint. function useThumbUrls(photos: LibraryPhoto[]): Record { const [urls, setUrls] = useState>({}); useEffect(() => { const next: Record = {}; for (const photo of photos) if (photo.thumb) next[photo.id] = URL.createObjectURL(photo.thumb); setUrls(next); return () => { for (const url of Object.values(next)) URL.revokeObjectURL(url); }; }, [photos]); return urls; }