import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react'; import { useI18n } from './i18n/I18nProvider'; import { go } from './nav'; import { canBrowseFolders, ensurePermission, getPhoto, jumpScan, listEditedIds, listFolders, listPhotos, pickFolder, readPhotoFile, removeFolder, renameFolder, scanSession, 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, rating or flag. A roll of date folders is three levels // deep at most; add a filter once the column stops being scrollable in practice. interface Node { // The tree's own key: a folder name, or `folder/sub/dir`. key: string; label: string; depth: number; count: number; } // The two things this screen remembers between visits: the folder that was open, // and how much room its column was given. const NODE_KEY = 'recipescam.library.node'; const COLUMN_KEY = 'recipescam.library.column'; const COLUMN_MIN = 88; const COLUMN_MAX = 460; const COLUMN_DEFAULT = 118; // Private mode throws on localStorage rather than returning null: a screen that // cannot remember is fine, a screen that cannot load is not. function remembered(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function keep(key: string, value: string): void { try { localStorage.setItem(key, value); } catch { // The choice just does not outlive the visit. } } 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(() => remembered(NODE_KEY)); // Rows whose children are folded away. A collapse is a way of looking at the // tree, not a property of the roll, so it is not remembered between visits. const [folded, setFolded] = useState>(() => new 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 // own — and shallow is what a folder of folders is for. const [deep, setDeep] = useState(true); const [up, setUp] = useState(null); const [view, setView] = useState<'preview' | 'grid'>('preview'); const [colWidth, setColWidth] = useState(() => { const saved = Number(remembered(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 the head of a tree — the column's // own name, or the row of a folder picked at the top level — which carries // folding the folders nested below the top level. 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, and the width its column // was dragged to. useEffect(() => { if (node) keep(NODE_KEY, node); }, [node]); useEffect(() => { keep(COLUMN_KEY, String(colWidth)); }, [colWidth]); // 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) => { setNote(null); try { const result = await startScan(folder); 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] ); 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] ); // The folders nested below a top-level row — what COLLAPSE ALL has to fold. A // top-level row is not one of them: it is its own fold, since one click on its // name shuts it, so the menu item would only repeat the row's own click. const nested = useMemo(() => new Set([...parents].filter((key) => key.includes('/'))), [parents]); // What the column draws: a folded row takes its whole subtree with it. const rows = useMemo( () => nodes.filter((n) => { const parts = n.key.split('/'); for (let i = 1; i < parts.length; i++) if (folded.has(parts.slice(0, i).join('/'))) return false; return true; }), [nodes, folded] ); // The folder the open node belongs to: its name titles the column, and its // handle is what the menu acts on. const root = folders.find((f) => f.name === rootName) ?? null; // 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 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' ? (
{shown.map((photo) => (
{tile(photo, () => void open(photo))}
{photo.name} {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')} /> ) : (
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')}` : ''}
) : (

{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 top-level row, and stands alone on the column's own name: the folders nested below the top level go, one line per folder, and no top-level row ever leaves the column — the row itself is one click away and its click already shuts it. */} {menu.root ? ( ) : null} {menu.folder ? ( ready.has(menu.folder.name) ? ( <> ) : ( ) ) : menu.root ? null : ( )}
) : 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; }