import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } 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 { isRawName, rawThumbnail, tiffThumbnail } from './engine/rawDevelop'; import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { cachedFolders, cachedPhotos, canBrowseFolders, clearLibraryCache, ensurePermission, findFolderRelationship, getPhoto, getPhotoThumbs, HEAD_BYTES, jumpScan, listEditedIds, listFolders, makeTile, normPath, onLibraryBlocked, pickFolder, putPhotoThumb, readPhotoFile, readPhotos, reconnectPhotosForFolders, removeFolder, renameFolder, scanAsked, scanBusy, scanQueue, scanSession, setRotation, setStar, stopScan, stopScanAt, unfinished, watchRows, watchScan, type LibraryDir, type LibraryFolder, type LibraryPhoto, type ScanMode, } from './engine/library'; import { backupNow, backupStatus, pickBackupFolder, restoreNow, type BackupStatus, } from './engine/libraryBackup'; // 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'; // The name moved with the default: subfolders used to be drawn unless the reader // shut them out, and the chip wrote that first answer down on the very first // visit — so the old key holds 'true' for every reader who never touched it, and // reading it would keep them on a default they never chose. const DEEP_KEY = 'recipescam.library.deep2'; const VIEW_KEY = 'recipescam.library.view'; const SORT_BY_KEY = 'recipescam.library.sortBy'; const SORT_DIR_KEY = 'recipescam.library.sortDir'; const MIN_STAR_KEY = 'recipescam.library.minStar'; const YEAR_KEY = 'recipescam.library.year'; const FROM_KEY = 'recipescam.library.from'; const TO_KEY = 'recipescam.library.to'; function TimeRangeSelectInput({ from, to, onChange, t, }: { from: string; to: string; onChange: (from: string, to: string) => void; t: (key: any, params?: any) => string; }) { const isSet = from !== 'any' || to !== 'any'; return (
- {isSet ? ( ) : null}
); } 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 strip's tile: `.adm-thumb` is 132px wide and `.adm-filmstrip` leaves 8px // between tiles, so a tile takes 140px of the strip wherever it sits. const TILE_STEP = 140; // How many tiles past each edge of the strip are drawn. Enough that a fast drag // meets pictures rather than placeholders, few enough that the count does not // matter: what is drawn is this many, whatever the shelf holds. const STRIP_KEEP = 8; // How many rows of the wall past each edge of its viewport are drawn. A row of // cards is a couple of hundred pixels tall, so this is the row under the eye and // two beyond it — enough that a flick lands on pictures. const WALL_ROWS = 2; // What `.adm-grid` leaves between its rows. A spacer has to stand the same // distance past the last row it replaces as the row itself would have. const WALL_GAP = 12; // 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_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 // same reason: the roll is the long thing, and neither the strip nor a folder // that has not moved is worth a frame of it. const CATALOGUE_MS = 5000; const CATCHUP_MS = 30000; // The two refusals the write comes back with when the folder is not there or the // browser is not letting this page write to it: the names `libraryBackup` throws // them by, and the ones the picker is the answer to. const needsPick = (err: unknown) => err instanceof Error && (err.message === 'no-backup-permission' || err.message === 'no-backup-folder'); // A word for the note's parenthesis: the name the browser gave the refusal, or // the short name the backup threw, and never a stack. const why = (err: unknown) => { if (err instanceof Error && err.message.startsWith('no-backup-')) return err.message.replace('no-backup-', ''); if (typeof DOMException !== 'undefined' && err instanceof DOMException) return err.name; return err instanceof Error ? err.message : String(err); }; // The catalogue is for people with an account. The shelves hold nothing the app // did not read off the visitor's own disk, but browsing them is still the one // screen that hands a folder over to be walked, and a guest one file picker away // from a folder that would be read for nothing is not the offer. The gate is // here rather than in the router so the scan never starts for a guest — the // catalogue component below is not mounted at all until the account is known. // // What the first paint reads is the answer the last visit got, the way the studio // reads its own session back (see `loadState`): `/me` is a round trip, and a // screen that draws nothing until it lands draws nothing for as long as the API // is away — the frames the reader was working through are already on this disk // and there is no reason to hold them behind it. The record only ever holds this // visitor's own last answer, and an answer the API then contradicts puts the note // back on screen the moment it arrives. const ACCOUNT_KEY = 'recipescam.library.account'; export function Library() { const { t } = useI18n(); const [state, setState] = useState<'loading' | 'guest' | 'ready'>( () => (recall(ACCOUNT_KEY) as 'guest' | 'ready' | null) ?? 'loading' ); useEffect(() => { let alive = true; api .me() .then((me) => { if (!alive) return; const next = me?.user ? 'ready' : 'guest'; keep(ACCOUNT_KEY, next); setState(next); }) // No answer is treated like no account: the API being away is not a // reason to open the shelves to whoever is on the page. It is also not an // answer, so it does not go in the record — the next visit asks again // rather than reading the outage as this account's standing. .catch(() => { if (alive) setState('guest'); }); return () => { alive = false; }; }, []); if (state === 'loading') return

{t('auth.busy')}

; if (state === 'guest') return (

{t('lib.locked')}{' '} {t('nav.login')}

); return ; } function Catalogue() { const { t } = useI18n(); 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 // looking at are already in hand. The read below replaces them a moment later. const [photos, setPhotos] = useState(() => cachedPhotos()); // 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); const nodeParts = (recall(NODE_KEY) ?? '').split('/'); const nodeParents = nodeParts.slice(1).map((_, i) => nodeParts.slice(0, i + 1).join('/')); if (saved === null) { return new Set(nodeParents); } 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. The node the reader picked is what the screen shows: the frames // in the folders under it are asked for, not assumed. A roll whose top level // holds date folders then reads as those folders, and the frames behind them // arrive when the reader says so. The answer is picked up where the last visit // left it, the same as the open node above. const [deep, setDeep] = useState(() => recall(DEEP_KEY) === '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'>(() => (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 // 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 [sortBy, setSortBy] = useState<'taken' | 'mtime' | 'type' | 'name' | 'star'>( () => (recall(SORT_BY_KEY) as any) || 'taken' ); const [sortDir, setSortDir] = useState<'desc' | 'asc'>( () => (recall(SORT_DIR_KEY) as any) || 'desc' ); const [minStar, setMinStar] = useState(() => Number(recall(MIN_STAR_KEY)) || 0); const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all'); const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any'); const [to, setTo] = useState(() => recall(TO_KEY) || 'any'); useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]); useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]); useEffect(() => { keep(MIN_STAR_KEY, String(minStar)); }, [minStar]); useEffect(() => { keep(YEAR_KEY, year); }, [year]); useEffect(() => { keep(FROM_KEY, from); }, [from]); useEffect(() => { keep(TO_KEY, to); }, [to]); // How far the frame is magnified, and the pan offset relative to viewport center. const [zoom, setZoom] = useState(1); const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const isDraggingRef = useRef(false); const dragStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); const viewportRef = useRef(null); 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); 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; rel?: string; node?: string; x: number; y: number } | null>(null); const [photoMenu, setPhotoMenu] = useState<{ photo: LibraryPhoto; x: number; y: number } | null>(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); // A reading of this roll that is still waiting its turn. The menu is where // that wait is called off, so it has to know one exists. const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false; // 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); // Whether another tab still holds the library database at the version it was // opened with — a tab left open across a build. Nothing can be read until it // goes, and the screen would otherwise sit blank with no reason on it. const [libraryBlocked, setLibraryBlocked] = useState(false); useEffect(() => { onLibraryBlocked(setLibraryBlocked); return () => onLibraryBlocked(null); }, []); // The folder the catalogue is copied into, and whether the browser is still // letting this page write to it — which it stops doing when the tab closes, so // the row below says so rather than letting a write fail quietly. The ref is // the same value for the scan that ends behind this screen's back: it reads it // without being subscribed to it. const [back, setBack] = useState(null); const backRef = useRef(null); const [backBusy, setBackBusy] = useState(false); const reload = useCallback(async () => { const [rows, edits] = await Promise.all([readPhotos(), listEditedIds()]); // A read that came back with nothing is a read that failed, not a catalogue // that emptied — and the frames it would have cleared are frames the reader // was working their way through. The strip keeps them. if (rows) setPhotos(rows); setEdited(edits); }, []); // --- the catalogue's own copy --------------------------------------------- const show = useCallback(async (status: BackupStatus) => { backRef.current = status; setBack(status); }, []); useEffect(() => { let alive = true; void backupStatus().then((status) => { if (alive) void show(status); }); return () => { alive = false; }; }, [show]); // Open the picker and take what it chose. False when the reader closed it, or // when there is no picker to open — and the one thing this is asked for is the // thing a folder cannot be reached without: it is where a folder is chosen, // where a remembered one is handed its permission back, and where a backup is // named, since the name of the folder is the name of the backup. const pickBackup = useCallback(async (): Promise => { try { await pickBackupFolder(); await show(await backupStatus()); return true; } 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 false; setNote(t('lib.failed')); return false; } }, [show, t]); // Write the catalogue into the folder. With none picked yet this is also the // way to pick one: one button, because a backup folder is not something to // choose and then not use. `quiet` is the run that follows a scan — it says // nothing, since a note about a backup would sit over the note about the scan. const backup = useCallback( async (quiet = false) => { if (backBusy) return; // A folder the browser has not let this page write to is a folder to pick // again, and the picker is the only thing that can hand the permission // back — a permission outlives the tab only while the tab does, so this is // the run that follows every restart. Without it the button wrote nothing // and said so, which is a backup that quietly stopped happening. A quiet // run never comes here: it is guarded by the permission it would ask for. if ((!backRef.current?.folder || !backRef.current.granted) && !(await pickBackup())) return; setBackBusy(true); if (!quiet) setNote(t('lib.backupRun', { done: 0, total: backRef.current?.photos ?? 0 })); try { for (let attempt = 0; ; attempt++) { try { const result = await backupNow((p) => setNote(t('lib.backupRun', { done: p.done, total: p.total }))); await show(await backupStatus()); if (!quiet) setNote(t('lib.backupDone', { n: result.photos, w: result.written })); break; } catch (err) { // What the row was reading is the permission of a moment ago, and // the browser is free to want the click again by the time the write // reaches the disk — asking for it again from here is a request // without one behind it, and a refusal. The picker is the only // thing that hands a folder back, so a refusal that names the // permission goes through it once; anything else is the folder // itself saying no, and twice is the browser's answer. // // The sentence on the screen cannot carry a reason — it is one line // over a strip of photographs — so the reason goes to the console // and a word of it into the note, which is the whole of what tells // a folder that was moved from a permission that lapsed. console.error('backup:', err); if (attempt === 0 && needsPick(err) && (await pickBackup())) continue; if (!quiet) setNote(t('lib.backupFailed', { why: why(err) })); break; } } } finally { setBackBusy(false); } }, [backBusy, pickBackup, show, t] ); // Where the copy goes, said out loud: the line that names the folder is the // control that changes it. A backup is a folder of rows and tiles, so the // folder's name — the one the reader gave it in the picker, or the disk it was // carried in on — is the whole of what tells one backup from another, and // nothing else on this screen can offer a name. const choose = useCallback(async () => { if (backBusy) return; await pickBackup(); }, [backBusy, pickBackup]); // The other direction, and the one that replaces rows: the frames come back // without a file handle — a handle is not a thing that can be written down — // and picking the folder again afterwards is what hands every one of them back, // without a frame being read. // // The folder is asked for every time, whatever this screen remembers, because // the folder is the whole of the choice: which backup to come back from is // which folder the reader points at — the copy on the other disk, the one made // before the edit. The count and the date on the confirm are read out of the // folder just picked, so the thing described is the thing restored, and a // folder with no catalogue of its own is a folder to point somewhere else at // rather than a run that replaces the reading with nothing. const restore = useCallback(async () => { if (backBusy) return; if (!(await pickBackup())) return; const status = backRef.current; if (!status?.at) { setNote(t('lib.restoreNothing', { folder: status?.folder ?? '' })); return; } const when = new Date(status.at).toLocaleString(); if (!window.confirm(t('lib.restoreConfirm', { folder: status.folder ?? '', n: status.photos, time: when }))) return; setBackBusy(true); setNote(t('lib.restoreRun', { done: 0, total: status.photos })); try { const result = await restoreNow((p) => setNote(t('lib.restoreRun', { done: p.done, total: p.total }))); await reload(); setFolders(await listFolders()); setNote(t('lib.restoreDone', { n: result.photos })); } catch { setNote(t('lib.restoreFailed')); } finally { setBackBusy(false); } }, [backBusy, pickBackup, reload, t]); // A reading that is over is the one moment the rows are finished, so it is the // moment the catalogue is copied out: a timer would fire in the middle of a // roll or not at all. Held in a ref because the watch below subscribes once and // this changes with every render — and nothing is written without a folder // picked and the browser still allowing the write, which are the two states the // row on screen is already saying. const afterScan = useRef(() => {}); afterScan.current = () => { const status = backRef.current; if (status?.folder && status.granted) void backup(true); }; // 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, and the frames themselves are handed over by the reading // as it stores them — so the strip fills with the roll and the only read back // is the one a reading in another window needs, whose rows never come here. useEffect(() => { // The folder the last pass was on, so the pass that comes back empty — the // one that says the scan is through — is recognisable as an end rather than // as a screen with nothing to do. let was: string | null = null; // How much of the roll the catalogue was last read back at, and the shape the // column was last drawn at: reading the whole catalogue back costs every // thumbnail in it, so it waits for a batch to have landed rather than for a // frame to have been read, and the column is redrawn only when a pass has // walked into something new. let wrote = -1; let drew = ''; // And when it was last read back. A batch lands every second or so on a // folder of JPEGs, and reading a catalogue of a hundred thousand frames is a // second and a half of the page held still and sixty megabytes of rows thrown // away — a scan that read it back every batch spent the roll doing nothing // else, and a folder of RAW, with a quarter of a gigabyte of LibRaw open // beside it, went past what a page is given and took the tab with it. On a // clock the strip still fills while the scan runs. let read = 0; // And how many frames arrived the short way, handed over by the reading // itself: a reading of this window's hands over every frame it stores, and // there is no read to do for any of them. let fresh = 0; const watching = () => { bump(); const session = scanSession(); const folder = session?.folder ?? null; // A reading that is over leaves the catalogue it filled behind: this screen // reads back the last of it, whether the reading was this window's or the // one beside it — and the window watching never saw it otherwise, because a // reading that is through has nothing left to hand over. if (was && !folder) { was = null; void reload(); afterScan.current(); return; } was = folder; if (!session) return; const { progress } = session; const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : ''; if (shape !== drew) { drew = shape; if (progress.dirs.length) { setDirs((prev) => { const map = new Map(prev.map((d) => [normPath(d.id).toLowerCase(), d])); let changed = false; for (const d of progress.dirs) { const k = normPath(d.id).toLowerCase(); if (!map.has(k)) { map.set(k, d); changed = true; } } return changed ? [...map.values()] : prev; }); } } // A window beside the reading is handed the progress of another build, which // may have no such count: the frames it has stored are then read back on a // frame, as they were before there was one. const written = progress.written ?? progress.done; const now = Date.now(); if (written !== wrote && now - read >= CATALOGUE_MS) { wrote = written; read = now; // Nothing came over the side, so the frames are another window's and // there is nothing else to draw them from. The reading in this window // handed its own over as they landed. if (!fresh) void reload(); fresh = 0; } }; let pendingRows: LibraryPhoto[] = []; let updateTimer: ReturnType | null = null; const flushPending = () => { if (pendingRows.length > 0) { const toAdd = pendingRows; pendingRows = []; // A frame handed over the side is a frame the read-back that landed beside // it already carries: a reading this window holds ends, the screen reads // the catalogue back, and the rows it was handed are still waiting out // their half second — so the same frame arrives twice and the header // counts it twice. The id is the frame, so one of it in the strip is // enough. setPhotos((prev) => { const have = new Set(prev.map((p) => p.id)); const landed = toAdd.filter((p) => !have.has(p.id)); return landed.length ? [...prev, ...landed] : prev; }); } updateTimer = null; }; watchRows((rows) => { fresh += rows.length; pendingRows.push(...rows); if (!updateTimer) { updateTimer = setTimeout(flushPending, 500); } }); 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 () => { if (updateTimer) clearTimeout(updateTimer); flushPending(); unwatch(); watchRows(null); }; }, [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); setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null))); // 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); // 4. Await photos reload if still running await reloadPromise; 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, the width its column was dragged to, and whether the strip drew the whole // branch under that folder or only the folder itself. 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]); useEffect(() => { keep(DEEP_KEY, String(deep)); }, [deep]); useEffect(() => { keep(VIEW_KEY, view); }, [view]); // Keep global memory caches in RAM during SPA tab navigation between STUDIO and LIBRARY // 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 && !photoMenu) return; const away = (e: Event) => { if (e.target instanceof Element && e.target.closest('.lib-menu')) return; setMenu(null); setPhotoMenu(null); }; const key = (e: KeyboardEvent) => { if (e.key === 'Escape') { setMenu(null); setPhotoMenu(null); } }; document.addEventListener('pointerdown', away); document.addEventListener('keydown', key); return () => { document.removeEventListener('pointerdown', away); document.removeEventListener('keydown', key); }; }, [menu, photoMenu]); // A reading asked for here is a request, not a reading: one walk runs at a time, // and a folder asked for while another is being read waits its turn — which is // what makes a right click on four folders mean four readings rather than three // refusals. The note lands when this request's turn comes, not when it is asked // for, and a request called off before it ran says nothing at all. const scan = useCallback( async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => { setNote(null); try { const result = await scanAsked(folder, from, mode); if (!result) return; if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, added: result.added, total: result.total })); // A reading kept to one folder names the folders under that one, which // is no reason to fold the column down to them: the roll is still the // roll, and the rows outside the folder being re-read are rows the // reader is looking at. if (!from) 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 — but only when the last reading of it never // finished. A reading that ran to its end has nothing left to find, and the // reader who opens the library to choose a frame is not a reader who asked for // their disk walked behind their back: on the folder they are opening, a walk // is a hundred thousand names read off the disk and the band and the memory // that goes with them, which is the whole of what this screen must not cost. // A folder the reader wants looked at again says so itself, from the menu on // its row; what is picked up here is the roll whose reading ended when the app // did, and nothing else. No note either way: the ring on the row and the // progress line are the report. const rootKey = node ? node.split('/')[0] : null; // When the folder was last walked for being raised. It stands outside the // effect because the effect is rebuilt for the folder the reader moves to, and // the walk it guards is of the folder on the disk, where nothing has moved // either way. const synced = useRef(0); useEffect(() => { if (!loaded) return; // Another window may hold the reading — the app beside the browser, which // this window cannot see. Asking is a round trip, and the answer is what // keeps this one from reading the same roll a second time. const catchUp = () => { if (scanSession()) return; if (Date.now() - synced.current < CATCHUP_MS) return; synced.current = Date.now(); void scanBusy().then(async (busy) => { if (busy || scanSession()) return; for (const folder of folders) { if (!ready.has(folder.name)) continue; if (await unfinished(folder.name)) { void scan(folder, false); break; } } }); }; catchUp(); const raised = () => { if (document.visibilityState === 'visible') catchUp(); }; document.addEventListener('visibilitychange', raised); return () => document.removeEventListener('visibilitychange', raised); }, [loaded, folders, ready, scan]); const add = useCallback(async () => { setNote(null); try { const folder = await pickFolder(); if (!folder) return; const relInfo = await findFolderRelationship(folder.handle, folders); if (relInfo.type === 'subfolder') { const { parent, relPath } = relInfo; const subNodeKey = `${parent.name}/${relPath}`; const parts = relPath.split('/'); setExpanded((prev) => { const next = new Set(prev); let curr = parent.name; next.add(curr); for (let i = 0; i < parts.length; i++) { curr = `${curr}/${parts[i]}`; next.add(curr); } return next; }); setDirs((prev) => { if (prev.some((d) => d.id === subNodeKey)) return prev; return [...prev, { id: subNodeKey, folder: parent.name, rel: relPath }]; }); setNode(subNodeKey); await scan(parent, false, relPath + '/'); } else if (relInfo.type === 'parent') { const { child } = relInfo; const nextFolders = [...folders.filter((f) => f.name !== child.name), folder].sort((a, b) => a.name.localeCompare(b.name) ); setFolders(nextFolders); setReady((prev) => new Set(prev).add(folder.name)); setNode(folder.name); await scan(folder); } else { 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) { if (err instanceof DOMException && err.name === 'AbortError') return; setNote(t('lib.failed')); } }, [folders, scan, t]); const reconnect = useCallback( async (folder: LibraryFolder) => { if (await ensurePermission(folder.handle)) { setReady((prev) => new Set(prev).add(folder.name)); await reconnectPhotosForFolders(folder.handle); 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 rootFolder = useMemo(() => { if (!node) return folders[0] ?? null; const normNode = (node || '').replace(/\\/g, '/'); return ( folders.find((f) => { const normF = (f.name || '').replace(/\\/g, '/'); return normNode === normF || normNode.startsWith(`${normF}/`); }) ?? folders[0] ?? null ); }, [folders, node]); const rootName = rootFolder ? rootFolder.name : 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 = useCallback( (key: string): string | null => { if (!rootName) return null; const normRoot = normPath(rootName); const normKey = normPath(key); if (normKey === normRoot) return ''; if (normKey.startsWith(`${normRoot}/`)) return `${normKey.slice(normRoot.length + 1)}/`; return null; }, [rootName] ); // 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. // A reading kept to one folder counts the frames of that folder and of what // lies under it, with nothing at all to say about the rest of the roll: its // 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.counts : null; const liveDirs = live ? live.progress.dirs : null; // 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(); const foldersUnder = new Map>(); const add = (folder: string, rel: string, counted: boolean) => { const normF = (folder || '').replace(/\\/g, '/'); const normRel = (rel || '').replace(/\\/g, '/'); const lowerF = normF.toLowerCase(); const set = foldersUnder.get(lowerF) ?? new Set(); foldersUnder.set(lowerF, set); let path = ''; for (const part of normRel.split('/')) { if (!part) continue; path = path ? `${path}/${part}` : part; set.add(path); if (counted) { const key = `${lowerF}/${path.toLowerCase()}`; hits.set(key, (hits.get(key) ?? 0) + 1); } } }; for (const photo of photos) { const normF = (photo.folder || '').replace(/\\/g, '/'); const normDir = (photo.dir || '').replace(/\\/g, '/'); hits.set(normF.toLowerCase(), (hits.get(normF.toLowerCase()) ?? 0) + 1); if (normDir) add(normF, normDir, true); } for (const dir of dirs) add(dir.folder, dir.rel, false); if (liveDirs) { for (const dir of liveDirs) add(dir.folder, dir.rel, false); } return { hits, foldersUnder }; }, [photos, dirs, liveDirs]); const nodes = useMemo(() => { const { hits, foldersUnder } = baseTreeInfo; const counted = (key: string) => { const normKey = (key || '').replace(/\\/g, '/'); const lowerKey = normKey.toLowerCase(); const hitVal = hits.get(lowerKey) ?? 0; const readVal = read?.[normKey] ?? read?.[lowerKey] ?? read?.[key] ?? 0; return Math.max(hitVal, readVal); }; const out: Node[] = []; for (const folder of folders) { const normF = (folder.name || '').replace(/\\/g, '/'); const lowerF = normF.toLowerCase(); out.push({ key: normF, label: folder.label || folder.name, depth: 0, count: counted(normF) }); // 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(lowerF) ?? [])].sort((a, b) => a.localeCompare(b))) { const parts = dir.split('/'); const key = `${normF}/${dir}`; out.push({ key, label: parts[parts.length - 1], depth: parts.length, count: counted(key), }); } } return out; }, [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. 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] ); // Where a mark goes on the column: the row for the folder the reading is // standing in, and every row above it up to and including the roll's own row. // Reading D:/A/B1/C2 shows the work on C2 and on B1, on A and on D: — the // whole path down to where the walk is. As the walk moves on, so does the // mark: on A the chain is D: alone, in C2 it is D:/A/B1/C2, and a fold shut // over a branch takes the rows below it out of the column without taking the // reading off the screen, because the rows above are still there to say it. // A reading that has not reached a frame yet stands nowhere, and stands on // the folder it was asked of — from the click, not from the first frame, so a // command is never a command the reader cannot tell from one that never ran. // The rows are found by the key the walk would give them — `folder/sub` — // rather than by taking the row's own name apart, which is the same path // spelled by two pieces of code and has to agree with the reading's own // spelling, not merely nearly. interface Mark { root: string; own: string; } const markOf = (folderName: string, rel: string, at: string): Mark => { const root = normPath(folderName); const cleanRel = (rel || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/$/, ''); const cleanAt = (at || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/$/, ''); const here = normPath(cleanAt || cleanRel); return { root, own: here ? `${root}/${here}` : root }; }; const liveMark = live ? markOf(live.folder, live.progress.from, live.progress.at) : null; // A request waiting its turn is not being read and has no folder to stand in, // so it marks the folder it was asked of. const askedMarks = scanQueue().map((j) => markOf(j.folder, j.rel, '')); const wearsMark = (n: Node, m: Mark): boolean => { const nKey = normPath(n.key).toLowerCase(); const mRoot = normPath(m.root).toLowerCase(); const mOwn = normPath(m.own).toLowerCase(); // Another roll's rows are neither the reading's nor the request's news. if (nKey !== mRoot && !nKey.startsWith(`${mRoot}/`)) return false; // The active scanning row itself, and every parent/ancestor row up to root. return nKey === mOwn || mOwn.startsWith(`${nKey}/`); }; // Auto-expand parent folders of the active scan path so deep subfolders (levels 4, 5, 6, 7+) // are visible in the tree while being scanned. useEffect(() => { if (!liveMark) return; const parts = normPath(liveMark.own).split('/'); if (parts.length <= 1) return; const toExpand: string[] = []; for (let i = 1; i < parts.length; i++) { toExpand.push(parts.slice(0, i).join('/')); } setExpanded((prev) => { const missing = toExpand.filter((k) => !prev.has(k)); if (!missing.length) return prev; const next = new Set(prev); for (const k of missing) next.add(k); return next; }); }, [liveMark?.own]); // 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 === rootName || n.key === normPath(rootName ?? ''))?.key ?? nodes[0]?.key ?? null); }, [loaded, node, nodes, rootName]); // What the open node draws: its own frames, and — when the reader has asked // for the branch — the ones in every subfolder under it. const shown = useMemo(() => { if (!rootName) return photos; const normRoot = normPath(rootName); const normNode = normPath(node ?? ''); const dir = normNode === normRoot ? '' : normNode.slice(normRoot.length + 1); const below = dir === '' ? (p: LibraryPhoto) => normPath(p.dir ?? '') !== '' : (p: LibraryPhoto) => normPath(p.dir ?? '').startsWith(`${dir}/`); return photos.filter((p) => { const pFolder = normPath(p.folder ?? ''); const pDir = normPath(p.dir ?? ''); return pFolder === normRoot && (pDir === dir || (deep && below(p))); }); }, [deep, node, photos, rootName]); // 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' && to !== 'any') { const f = Number(from); const tNum = Number(to); const h = at.getHours(); if (f <= tNum) { if (h < f || h > tNum) return false; } else { if (h < f && h > tNum) return false; } } else { if (from !== 'any' && at.getHours() < Number(from)) return false; if (to !== 'any' && at.getHours() > Number(to)) return false; } return true; }); const getExt = (filename: string) => { const idx = filename.lastIndexOf('.'); return idx !== -1 ? filename.slice(idx + 1).toLowerCase() : ''; }; const multiplier = sortDir === 'asc' ? 1 : -1; return [...rows].sort((a, b) => { let diff = 0; if (sortBy === 'taken') { diff = a.taken - b.taken; } else if (sortBy === 'mtime') { diff = (a.mtime ?? a.taken) - (b.mtime ?? b.taken); } else if (sortBy === 'name') { diff = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); } else if (sortBy === 'type') { const extA = getExt(a.name); const extB = getExt(b.name); diff = extA.localeCompare(extB) || a.name.localeCompare(b.name); } else if (sortBy === 'star') { diff = (a.star ?? 0) - (b.star ?? 0); } if (diff === 0) { diff = a.taken - b.taken; } return diff * multiplier; }); }, [shown, minStar, year, from, to, sortBy, sortDir]); const frame = listed.find((p) => p.id === up) ?? listed[0] ?? null; // The strip draws the tiles around the one under the eye, not the whole shelf. // A roll is thousands of frames and every tile drawn costs a ` {live || scanQueue().length ? ( ) : null} {/* The catalogue's own copy: one button that picks the folder the first time and writes into it every time after, and one that reads it back. Both are furniture next to the reading itself, so they sit behind the hint rather than in front of it. */} {/* The folder is a control, not a caption: it is the whole of what tells one backup from another, and pressing it is how a folder is picked, a name given, a permission handed back. */} {t('lib.hint')} {/* Nothing is drawn until the reading knows what it is reading: a roll of nothing over nothing is the line talking before the first pass has come back, which is a scan of a hundred thousand frames announced as `0/0`. */} {live && live.progress.total ? 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} {libraryBlocked ? (

{t('lib.blocked')}

) : null} {/* The folders are on their way in for as long as the catalogue behind them is being read back, and a screen that says there is no folder before it has looked is a screen that says it every visit. */} {loaded && 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))); }} />
{/* 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. */}
{/* 1. Ascending / Descending (Đầu hàng) */} {/* 2. Date taken / Sort By */} {/* 3. Year */} {/* 4. Time taken (From / To dropdown selects) */} { setFrom(f); setTo(tStr); }} t={t} /> {/* 5. Rating (Cuối cùng) */} {/* 6. Count */} {t('lib.count', { n: listed.length })}
{view === 'grid' ? ( <> {/* The wall is the shelf's own length, so the scrollbar on its right edge still reaches every frame; what is drawn is the rows around the eye, with a spacer standing in for the rows above and below. A card is a card, an and an object URL of its own, and twenty thousand of them is a dead screen. */}
{wall.above > 0 ?
) : frame ? ( <>
1 ? (dragStartRef.current ? 'grabbing' : 'grab') : 'pointer', userSelect: 'none', touchAction: 'none', }} > {stageTurned ? ( { if (isDraggingRef.current) { isDraggingRef.current = false; return; } void open(frame); }} onContextMenu={(e) => { e.preventDefault(); setPhotoMenu({ photo: frame, x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 160), }); }} title={t('lib.open')} style={{ width: '100%', height: '100%', objectFit: 'contain', transform: zoom === 1 && pan.x === 0 && pan.y === 0 ? undefined : `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: 'center center', transition: dragStartRef.current ? 'none' : 'transform 0.05s ease-out', }} /> ) : (
void open(frame)} onContextMenu={(e) => { e.preventDefault(); setPhotoMenu({ photo: frame, x: Math.min(e.clientX, window.innerWidth - 220), y: Math.min(e.clientY, window.innerHeight - 160), }); }} 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 standing: a negative shot on its side is turned here, once, and the catalogue keeps the angle — the next visit, the next scan and the studio all read it off the row, so it is never turned twice. Two buttons and not a dial: a quarter is the only turn a photo is ever out by. They sit on the centre of the bar, over the middle of the picture above them, because they are what the eye comes down to after it; the score stays out at the edge. */} {/* 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) — the ones around the eye, with a spacer standing in for each run that is not drawn, so the scrollbar still measures the whole shelf. */} {view === 'preview' ? (
{ if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY; }} > {strip.from > 0 ? (
) : 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) || ready.has((menu.folder.name || '').replace(/\\/g, '/'))) ? ( <> {!menu.root ? ( ) : null} {/* Three things can be asked of a roll: bring it up to date, read it from the top again, or leave it — and the last only of the reading that is this roll's, or of the request for it that is still waiting its turn. The first two are always there, because a reading already running is not a reason a reader cannot ask for the next one: it queues. */} {live?.folder === menu.folder.name || queued ? ( ) : null} {/* A row with folders under it is a row that folds, and the branch it holds goes with it: a reader who is done with a tree means the whole of it shut, not the one row they happened to point at with its children left open underneath. */} {menu.node && parents.has(menu.node) ? ( ) : null} ) : ( ) ) : ( )}
) : null} {photoMenu ? (
e.preventDefault()} > {selectedIds.size >= 2 ? (
{t('lib.count', { n: selectedIds.size })}
) : null}
) : null}
); } // A frame's own pixels at the angle its row carries. The turn is baked into the // bitmap rather than put on the element, because everything drawn over the photo // on this stage measures that element: the wheel's zoom takes its aim from the // element's own box and the frame is fitted to it, and both are read off a box // the browser has already swapped the sides of once the element is turned. A // quarter turn is a pixel swap — nothing is interpolated — so the canvas only // pays the re-encode. The unturned frame stands while the turn is being made, // which keeps a click from blanking the stage; and a frame at 0 hands the source // straight on, so an unturned roll decodes nothing. function useTurned(src: string | null, deg: number): string | null { const [made, setMade] = useState<{ url: string; for: string } | null>(null); useEffect(() => { const key = `${src}|${deg}`; if (!src || !deg) return; let alive = true; let url: string | null = null; void (async () => { try { const bmp = await createImageBitmap(await (await fetch(src)).blob()); const swap = deg % 180 !== 0; const canvas = new OffscreenCanvas(swap ? bmp.height : bmp.width, swap ? bmp.width : bmp.height); const ctx = canvas.getContext('2d'); if (!ctx) { bmp.close(); return; } ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate((deg * Math.PI) / 180); ctx.drawImage(bmp, -bmp.width / 2, -bmp.height / 2); bmp.close(); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.95 }); if (!alive) return; url = URL.createObjectURL(blob); setMade({ url, for: key }); } catch { // Undecodable here: the frame stands as the file wrote it. } })(); return () => { alive = false; if (url) URL.revokeObjectURL(url); // The turn this run made goes with it. The element would otherwise be // handed a revoked URL the moment the angle moves back to one this same // source was already turned to. setMade((prev) => (prev && prev.url === url ? null : prev)); }; }, [src, deg]); return made && made.for === `${src}|${deg}` ? made.url : src; } // A tile, at the angle its row carries: the wall and the strip read the same // turn the stage does, so a frame the reader has stood upright is not lying on // its side two rows down. One canvas per turned frame and none at all for the // rest — a tile at 0 hands its URL straight on. function TileImg({ src, rot, onPick, title }: { src: string; rot: number; onPick?: () => void; title?: string }) { return ; } // One object URL per tile, made when the row does and dropped with it: a folder const globalThumbUrls = new Map(); const GLOBAL_URL_MAX = 600; 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 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; if (!list.length) { setUrls({}); return; } 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(async (thumbMap) => { if (!alive) return; const updates: Record = {}; for (const photo of list) { let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; if (!blob && photo.handle) { try { const file = await readPhotoFile(photo); if (!alive) return; const name = file.name; if (isHeicName(name)) { const fullBytes = new Uint8Array(await file.arrayBuffer()); let prevBytes = await heicThumbnail(fullBytes); if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320); if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' }); } else if (isRawName(name) || /\.(tiff?)$/i.test(name)) { const fullBytes = new Uint8Array(await file.arrayBuffer()); let prevBytes = await rawThumbnail(fullBytes, name); if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes); if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' }); } else { // A frame the wall can decode itself: the tile is made here, at the // size the wall paints, because this is the first time anyone has // looked at the frame. The walk never made one — a roll reads in a // fraction of the time without it — so the decode is paid on the // frame the reader draws and kept for every visit after. blob = (await makeTile(file)) ?? file; if (blob !== file) void putPhotoThumb(photo.id, blob); } } catch { blob = null; } } if (blob && alive) { updates[photo.id] = cacheObjectUrl(photo.id, blob); } } if (alive && Object.keys(updates).length > 0) { setUrls((prev) => ({ ...prev, ...updates })); } }); return () => { alive = false; }; }, [list]); return urls; } // Which tiles of the strip are drawn: the run under the viewport and `STRIP_KEEP` // either side of it. Without this the strip builds a tile for every frame the open // node holds before the reader has seen one — 400ms of DOM and a second of object // URLs on a roll of twenty thousand — and the screen is dead for the whole of it. // `on` is the strip being up: the element the window measures is the strip's own. function useStripWindow(count: number, on: boolean): { ref: RefObject; from: number; to: number } { const ref = useRef(null); // The first window is a screenful: the strip is not in the DOM yet when this // runs, so what it measures is the next paint's, and this is what the first // paint draws. const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, STRIP_KEEP * 2 + 24) })); useEffect(() => { if (!on) return; const el = ref.current; if (!el) return; const measure = () => { const from = Math.max(0, Math.floor(el.scrollLeft / TILE_STEP) - STRIP_KEEP); const to = Math.min(count, Math.ceil((el.scrollLeft + el.clientWidth) / TILE_STEP) + STRIP_KEEP); setWin((prev) => (prev.from === from && prev.to === to ? prev : { from, to })); }; measure(); el.addEventListener('scroll', measure, { passive: true }); const ro = new ResizeObserver(measure); ro.observe(el); return () => { el.removeEventListener('scroll', measure); ro.disconnect(); }; }, [count, on]); return { ref, from: win.from, to: win.to }; } // Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS` // either side, with a spacer standing in for the rest so the scrollbar still spans // the shelf. The wall holds every frame the open node does — a filter is a way of // looking, not a second shelf — and a card is a card, an `` and an object URL // of its own; twenty thousand of them is a dead screen for as long as they take to // build. `on` is the wall being up, since the element measured is its own. function useWallWindow(count: number, on: boolean): { ref: RefObject; from: number; to: number; above: number; below: number } { const ref = useRef(null); // The first window is a screenful: the wall has not been measured yet. const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 })); const measureRef = useRef<() => void>(() => {}); // The window over a scroll container is taken rather than kept, so it is taken // after every render: the first pass draws a screenful, the next reads the row's // step off it and lays out the spacers, and the pass after that leaves both // alone. The listeners are for the two things no render can see coming — a // scroll of the wall, and the window itself changing size. useEffect(() => { if (!on) return; const el = ref.current; if (!el) return; const measure = () => { const cards = el.querySelectorAll(':scope > .adm-card'); const first = cards[0]; let cols = 0; if (first) while (cols < cards.length && cards[cols].offsetTop === first.offsetTop) cols++; // One row, or a wall not yet drawn: open on the first screenful and let the // next pass read the step off what it drew. if (cards.length <= cols) { const to = Math.min(count, 24); setWin((prev) => (prev.from === 0 && prev.to === to && !prev.above && !prev.below ? prev : { from: 0, to, above: 0, below: 0 })); return; } // The step from one row to the next, taken as the drawn run's own average. // The cards do NOT all stand the same height — a caption that wraps makes // its row taller — and `offsetTop` is rounded to whole pixels, so the // smallest step would be a pixel short on every one of thousands of rows. // The average of the rows in hand is the honest guess at the ones that are // not in hand. const rowsDrawn = Math.floor(cards.length / cols); if (rowsDrawn < 2) return; const pitch = (cards[(rowsDrawn - 1) * cols].getBoundingClientRect().top - cards[0].getBoundingClientRect().top) / (rowsDrawn - 1); if (!(pitch > 0)) return; const rows = Math.ceil(count / cols); const row0 = Math.max(0, Math.floor(el.scrollTop / pitch) - WALL_ROWS); const row1 = Math.min(rows, Math.ceil((el.scrollTop + el.clientHeight) / pitch) + WALL_ROWS); const from = row0 * cols; const to = Math.min(count, row1 * cols); // A spacer's own height keeps the gap the grid would have left under the // last row it stands in for. const above = row0 ? row0 * pitch - WALL_GAP : 0; const below = row1 < rows ? (rows - row1) * pitch - WALL_GAP : 0; setWin((prev) => prev.from === from && prev.to === to && prev.above === above && prev.below === below ? prev : { from, to, above, below } ); }; measureRef.current = measure; const onScroll = () => measure(); el.addEventListener('scroll', onScroll, { passive: true }); const ro = new ResizeObserver(onScroll); ro.observe(el); return () => { measureRef.current = () => {}; el.removeEventListener('scroll', onScroll); ro.disconnect(); }; }, [count, on]); useLayoutEffect(() => { measureRef.current(); }); return { ref, from: win.from, to: win.to, above: win.above, below: win.below }; }