diff --git a/docker/frontend/public/manifest.json b/docker/frontend/public/manifest.json index fb377e0..7da8d24 100644 --- a/docker/frontend/public/manifest.json +++ b/docker/frontend/public/manifest.json @@ -1,6 +1,7 @@ { "name": "RecipesCam — Color Recipes & Film Camera Studio", "short_name": "RecipesCam", + "id": "/", "description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.", "lang": "vi", "start_url": "/app", diff --git a/docker/frontend/scripts/tree-scan-check.mjs b/docker/frontend/scripts/tree-scan-check.mjs index c7fcce2..a55fc6d 100644 --- a/docker/frontend/scripts/tree-scan-check.mjs +++ b/docker/frontend/scripts/tree-scan-check.mjs @@ -281,11 +281,16 @@ await page.evaluate(() => localStorage.setItem('check.hold', '1')); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); // Every row of the pass is a row now, and a count beside each name is what the -// walk met — the reading itself has read nothing. +// walk met — the reading itself has read nothing. The whole tree is drawn in one +// go and never a row at a time, so the wait is for all of it: a count in between +// is a state this screen does not have, and waiting for one is a wait that runs +// out its clock and then measures whatever the reading has done since. The +// reading is held on the frame it is reading, and how long a held frame stays +// held is the engine's to decide (`READ_MS`), not this file's. await page .waitForFunction(() => { const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')]; - return rows.length === 4 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''); + return rows.length >= 5 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''); }, null, { timeout: 60_000, polling: 50 }) .catch(() => {}); const midFloat = await counts(); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index ab73a5b..febb033 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -44,6 +44,7 @@ import { setStar, stopScan, stopScanAt, + stopCount, unfinished, watchRows, watchScan, @@ -211,7 +212,12 @@ const WALL_LANES = 2; // the rest as it scrolls. A job over every frame of the roll is a decode of every // frame of the roll — half an hour of disk for a folder nobody has scrolled past // the first screen of. -const THUMB_AHEAD = 400; +// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main +// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one +// folder opening is the reader waiting on tiles for frames three screens below +// the one they are looking at. The wall mints the rest as it scrolls, which is +// who the queue was for. +const THUMB_AHEAD = 60; // 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; @@ -257,7 +263,7 @@ function keepShot(id: string, promise: Promise): void { // 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 CATALOGUE_MS = 30000; const CATCHUP_MS = 30000; // The two refusals the write comes back with when the folder is not there or the @@ -274,6 +280,23 @@ const why = (err: unknown) => { return err instanceof Error ? err.message : String(err); }; +// Whether two handles are the same folder on the disk. It is what tells a reader +// picking the folder they already have — a permission to hand back, a roll to +// read again — from a second folder that happens to be called the same thing. +// The API is Chromium's own; a browser without it is a browser where every +// same-named folder is taken for another one, which is the older behaviour. +async function sameDirectory( + a: FileSystemDirectoryHandle, + b: FileSystemDirectoryHandle | undefined +): Promise { + if (!b || typeof a.isSameEntry !== 'function') return false; + try { + return await a.isSameEntry(b); + } catch { + return false; + } +} + // The catalogue is for people with an account. The shelves hold nothing the app @@ -650,6 +673,15 @@ function Catalogue({ admin }: { admin: boolean }) { // 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; + // Whether the reading that has just left was stopped rather than finished. + // The copy out is made for a reading that ran to its end; one that was called + // off gets nothing but the read back. + let stopped = false; + // The stop count the reading started at. A reading hands over its folder and + // its numbers and nothing about why it ended — a stop is not in the record it + // leaves — so the screen works it out on the count the engine keeps of every + // stop, this window's or the one beside's. + let stopMark = 0; // 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 @@ -678,22 +710,35 @@ function Catalogue({ admin }: { admin: boolean }) { // 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) { + if (stopCount() !== stopMark) stopped = true; was = null; void reload(); - afterScan.current(); + // A reading the reader stopped is a reading that did not finish: the copy + // out reads the whole catalogue and every tile in it, and the peak that + // makes is the thing the STOP press was getting away from. + if (!stopped) afterScan.current(); + stopped = false; return; } + // Where a reading's start is counted from: the first poll that sees a + // folder there is the first poll after the reading began. + if (!was && folder) stopMark = stopCount(); was = folder; if (!session) return; const { progress } = session; - const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : ''; + // A window beside the reading is handed the progress of another build, and + // a build whose progress carries no folder list at all is a build whose + // reading this is not — reading a length off it is a throw, and a throw in + // here leaves the scan unwatched in this window for the rest of its life. + const dirs = progress.dirs ?? []; + const shape = dirs.length ? `${dirs.length}:${dirs[dirs.length - 1].id}` : ''; if (shape !== drew) { drew = shape; - if (progress.dirs.length) { + if (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) { + for (const d of dirs) { const k = normPath(d.id).toLowerCase(); if (!map.has(k)) { map.set(k, d); @@ -894,13 +939,21 @@ function Catalogue({ admin }: { admin: boolean }) { 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 })); + // The name the reader gave a folder wins over the one the browser had + // for it: a drive root's own name is `\`, which is no name at all. See + // the label the ADD passes to `putFolder`. + const named = folder.label || folder.name; + if (!quiet) setNote(t('lib.scanned', { folder: from ? `${named}/${from.slice(0, -1)}` : named, 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(); + // The repair reads every roll in the catalogue one after another, and a + // read of the whole catalogue between two rolls — plus the copy out + // `afterScan` makes after each one — is the repair's own way of running + // a page out of memory. It reads back once, at its end. + if (!quiet) await reload(); } catch { setNote(t('lib.failed')); } @@ -923,8 +976,18 @@ function Catalogue({ admin }: { admin: boolean }) { if (!rolls.length) return; if (!window.confirm(t('lib.repairConfirm', { n: rolls.length }))) return; setNote(t('lib.repairRun', { n: rolls.length })); + // The stop button stops the afternoon, repair included: the count is read + // before each roll and a press of STOP anywhere in between — this window's or + // the window beside's, which is told over the same channel — ends the loop + // instead of moving on to the next roll. + const mark = stopCount(); let done = 0; + let stopped = false; for (const folder of rolls) { + if (stopCount() !== mark) { + stopped = true; + break; + } // One roll's failure is one roll's: a handle the browser will not hand // back, a drive that is not there — the rest of the repair still runs. try { @@ -935,7 +998,7 @@ function Catalogue({ admin }: { admin: boolean }) { } } await reload(); - setNote(t('lib.repairDone', { n: done })); + setNote(stopped ? t('lib.repairHalt', { n: done }) : t('lib.repairDone', { n: done })); }, [folders, reload, scan, t]); // A reading that ran to its end has nothing left to find, and the reader who @@ -994,9 +1057,32 @@ function Catalogue({ admin }: { admin: boolean }) { const add = useCallback(async () => { setNote(null); try { - const folder = await pickFolder(); - if (!folder?.handle) return; - const handle = folder.handle; + const picked = await pickFolder(); + if (!picked?.handle) return; + const handle = picked.handle; + + // A folder is named, and everything downstream is named after it: the + // record in the store, the frame ids, the file the walk's position is kept + // in, and the number the column draws. Two folders with one name are one + // folder to all of it — and that is exactly what two drives on Windows are, + // because the browser hands every drive root the same name. So a folder the + // browser could not name is asked for one, and a folder whose name is + // already another folder's is refused rather than folded in with it. + const norm = normPath(picked.name); + const clash = folders.find((f) => normPath(f.name) === norm); + const mine = clash ? await sameDirectory(handle, clash.handle) : false; + if (clash && !mine) { + setNote(t('lib.dupFolder', { folder: clash.label || clash.name || picked.name })); + return; + } + let folder = picked; + if (!norm || (clash && !clash.label)) { + const asked = window.prompt(t('lib.renameAsk'), clash?.label ?? '')?.trim(); + if (asked) { + folder = { ...picked, label: asked }; + await putFolder(folder); + } + } const relInfo = await findFolderRelationship(handle, folders); @@ -2068,7 +2154,11 @@ function Catalogue({ admin }: { admin: boolean }) { - {note ?

{note}

: null} + {/* The note is not drawn here any more. It is one line that comes and goes + — every scan and every press writes it — and a line in the flow above + the frame moved the frame, the column and the strip up and down by its + own height every time it appeared or went. It sits under the lot now, + in a row that is always there: see `.lib-status`. */} {canBrowseFolders() || servers.length ? (
@@ -2192,7 +2282,12 @@ function Catalogue({ admin }: { admin: boolean }) { if (folder) void scan(folder, false); }} > - {t('lib.resume', { folder: resume })} + + {/* `resume` is the folder's own name, which is what the store + is keyed by; the chip reads the label the reader gave it, + because a drive root's own name is `\`. */} + {t('lib.resume', { folder: folders.find((f) => f.name === resume)?.label || resume })} + ) : null} @@ -3128,6 +3223,12 @@ function Catalogue({ admin }: { admin: boolean }) { onClose={() => setEditAlbums(null)} /> ) : null} + + {/* The whole screen's one line of news, and the last thing in it: the frame + above keeps its height whether there is anything to say or not, so a + scan starting or ending no longer moves the wall. `:empty` hides the + text without taking the row away. */} +

{note}

); } diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index b576031..25be14e 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -725,6 +725,30 @@ export const HEAD_BYTES = 256 * 1024; // the first two hundred kilobytes is one read once more with this much of itself // rather than one filed under the clock on the file. const DEEP_HEAD_BYTES = 4 * 1024 * 1024; +// How long one touch of the disk may take before the reading gives up on that +// frame. `getFile` and `arrayBuffer` on a drive that has gone away — a network +// share that dropped, a handle the browser took back — do not fail, they never +// settle: the lane waits for ever, the reading never ends, and STOP is a button +// that does nothing because there is nothing left to read it. A frame given up on +// is a frame the next reading takes up again, which is what the position on the +// disk is. +const READ_MS = 30000; + +function withTimeout(work: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const bell = setTimeout(() => reject(new Error('read-timeout')), ms); + work.then( + (v) => { + clearTimeout(bell); + resolve(v); + }, + (e) => { + clearTimeout(bell); + reject(e); + } + ); + }); +} // The probe is decoded at this width, purely to learn which edge of the frame is // the long one; the decoder scales the real one off that. A JPEG does not need // it: its own header says how big the frame is, in the first few hundred bytes. @@ -1277,7 +1301,14 @@ export async function scanFolder( const due = () => batch.length > 0 && (batch.length >= BATCH || Date.now() - flushedAt >= FLUSH_MS); const readOne = async (handle: FileSystemFileHandle, rel: string): Promise => { - const file = await handle.getFile(); + // The flag is read here and not only at the top of the frame: a lane already + // handed a frame is a lane on its way to the disk, and a frame it opens after + // STOP is a frame the reading was told not to want. + if (shouldStop?.()) { + stop = true; + return; + } + const file = await withTimeout(handle.getFile(), READ_MS); const id = photoId(folder.name, rel); const seen = known.get(id); if (seen && seen.size === file.size && seen.mtime === file.lastModified) { @@ -1297,10 +1328,10 @@ export async function scanFolder( // has to do. The tile is the wall's business — one is made when one is drawn // and not before — see `makeTile`. const rawish = isRawName(file.name) || isTiffName(file.name) || isHeicName(file.name); - const bytes = new Uint8Array(await file.slice(0, Math.min(file.size, HEAD_BYTES)).arrayBuffer()); + const bytes = new Uint8Array(await withTimeout(file.slice(0, Math.min(file.size, HEAD_BYTES)).arrayBuffer(), READ_MS)); let taken = await readCapturedAt(bytes); if (taken === null && rawish && file.size > HEAD_BYTES) { - const deeper = await file.slice(0, Math.min(file.size, DEEP_HEAD_BYTES)).arrayBuffer(); + const deeper = await withTimeout(file.slice(0, Math.min(file.size, DEEP_HEAD_BYTES)).arrayBuffer(), READ_MS); taken = await readCapturedAt(new Uint8Array(deeper)); } const relNorm = rel.replace(/\\/g, '/'); @@ -1446,6 +1477,15 @@ export async function scanFolder( publish(true); } await drain(); + // A roll takes minutes and the tab can go at any point in them; what the + // reading has found so far is written down a pass at a time rather than once + // at the end, so a tab that never comes back leaves the column with the + // folders this reading walked instead of the ones the last reading did. + // ponytail: one IDB read and write of the whole dir list per pass — a pass is + // a layer of the walk, tens of them for a roll of a hundred thousand, and the + // write is small next to the frames it is about. Batch it only if a roll ever + // grows passes into the thousands. + await saveWalkedDirs(folder.name, dirs, counts, false, bounded ? from : '', failed); // Yield between pass iterations to maintain UI fluidness await new Promise((resolve) => setTimeout(resolve, 0)); } while (!walked && !stop); @@ -1472,9 +1512,15 @@ export async function scanFolder( // reading of the whole roll that reached its end is the roll and replaces what // was there — a folder it did not meet is a folder that is gone, and its number // with it. A reading kept to one folder knows one branch of the roll and is - // folded in; a reading that was stopped keeps its numbers for the folders it - // walked and none for the rest, which is what it counted. See `saveWalkedDirs`. - await saveWalkedDirs(folder.name, dirs, stop ? null : counts, !stop && !bounded, bounded ? from : '', failed); + // folded in. A reading that was stopped hands over the numbers it counted and + // says nothing of the folders it never reached: those are not in `dirs` either, + // so they keep the number the last reading left. A nought beside a walked + // folder comes from there being no frame under it, not from the reading being + // cut off — handed `null` instead, a stopped reading threw away its own count + // and had noughts written for every folder it walked that the file did not + // already hold, and a folder added to a drive read nought until the next scan. + // See `saveWalkedDirs`. + await saveWalkedDirs(folder.name, dirs, counts, !stop && !bounded, bounded ? from : '', failed); // The last word, whatever the clock says: the frames the reading got to are // the frames the column is about to be told about one last time. publish(true); @@ -1648,12 +1694,23 @@ export async function unfinished(folder: string): Promise { return (await loadWalk(folder)) !== null; } +// How many times a reading has been called off since the page opened. A reading +// of several rolls in a row — the repair — is one loop the stop button has no +// handle on: it holds the count it started at and compares, so a press of STOP +// between two rolls ends the loop instead of moving on to the next one. +let stops = 0; + +export function stopCount(): number { + return stops; +} + export function stopScan(): void { // Everything waiting is a reading that is not going to happen: the button that // stops the walk stops the afternoon, and a queue that ran on after it would be // a stop that stopped one folder and then read the next three anyway. waiting.forEach(drop); waiting = []; + stops++; tell(); // A reading another window holds is stopped by telling it so: the button this // window drew is the same button, on the window that can act on it. @@ -1676,6 +1733,7 @@ export function stopScanAt(folder: string, rel: string): void { if (job.folder === folder && (rel === '' || job.rel === rel)) drop(job); } waiting = waiting.filter((j) => !j.dropped); + stops++; if (live?.folder === folder) live.stop = true; else if (peer?.folder === folder) say({ k: 'stop', folder }); tell(); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8545afd..2a78f2c 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -492,6 +492,24 @@ export const en: Dict = { 'offline.queued': 'the server is out of reach, so this change is saved here and sent when it is back.', 'offline.pending': 'Not sent yet: {n}', + // A folder the reader pointed at that is already in the library. + 'lib.dupFolder': '"{folder}" is already in the library.', + // A repair the reader cut short, told apart from one that ran to its end. + 'lib.repairHalt': 'Repair stopped: {n} folders read back before it was stopped.', + + // The crash screen (src/ui/ErrorBoundary.tsx). + 'crash.title': 'The app hit an error', + 'crash.body': 'Something went wrong. The details are below — send them along if it keeps happening.', + 'crash.reload': 'Reload', + + // The one-window guard (src/ui/SingleTab.tsx). + 'tab.title': 'RecipesCam is open in another tab', + 'tab.body': 'The app runs one tab at a time. Use that one, or move it to this tab.', + 'tab.here': 'Use this tab', + 'tab.close': 'Close this tab', + 'tab.taken': 'Another tab just opened RecipesCam and took over.', + 'tab.retake': 'Take it back', + 'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)', }; diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 03485a5..5ca33b9 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -499,6 +499,26 @@ export const vi = { 'offline.queued': 'máy chủ không tới được, nên thay đổi này đã lưu trên máy và sẽ gửi khi kết nối lại.', 'offline.pending': 'Chưa gửi được: {n}', + // A folder the reader pointed at that is already in the library. The picker + // gives no name back to compare by eye, so the note names the one that is there. + 'lib.dupFolder': 'Thư mục "{folder}" đã có trong thư viện.', + // A repair the reader cut short, told apart from one that ran to its end. + 'lib.repairHalt': 'Đã dừng sửa dữ liệu: đọc lại {n} thư mục trước khi dừng.', + + // The crash screen (src/ui/ErrorBoundary.tsx). Rare, and the only thing a + // report of a blank page will have to carry. + 'crash.title': 'Ứng dụng gặp lỗi', + 'crash.body': 'Đã có lỗi xảy ra. Chi tiết ở dưới — gửi lại nếu còn tiếp diễn.', + 'crash.reload': 'Tải lại', + + // The one-window guard (src/ui/SingleTab.tsx). + 'tab.title': 'RecipesCam đang mở ở tab khác', + 'tab.body': 'Ứng dụng chỉ chạy một tab một lúc. Dùng tab kia, hoặc chuyển sang tab này.', + 'tab.here': 'Dùng tab này', + 'tab.close': 'Đóng tab này', + 'tab.taken': 'Tab khác vừa mở RecipesCam và tiếp quản.', + 'tab.retake': 'Lấy lại', + 'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)', } as const; diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 4fee423..6a8277c 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -12,8 +12,9 @@ import { Admin } from './Admin'; import { ProfilePage } from './ProfilePage'; import { PhotosPage } from './PhotosPage'; import { Library } from './Library'; -import { scanSession } from './engine/library'; import { go } from './nav'; +import { ErrorBoundary } from './ui/ErrorBoundary'; +import { OtherWindowNotice, TakenBar, useSingleWindow } from './ui/SingleTab'; import { InstallPrompt } from './ui/InstallPrompt'; import { OfflineBar } from './ui/OfflineBar'; import { installTracking } from './track'; @@ -22,15 +23,14 @@ import { installTracking } from './track'; // screen, the member's own profile, the member's photo folder and the local // catalogue of folders on the visitor's own disk. nginx serves index.html for // all of them (SPA fallback), so this is a pathname check. -function pick(route: string) { - return route.startsWith('/app') ? ( - - ) : route.startsWith('/admin') ? ( +// +// The pages that are not the two heavy screens: they read no catalogue and boot +// no renderer, so they are drawn straight from the route and nothing is kept. +function page(route: string) { + return route.startsWith('/admin') ? ( ) : route.startsWith('/photos') ? ( - ) : route.startsWith('/library') ? ( - ) : route.startsWith('/profile') ? ( ) : ( @@ -38,6 +38,14 @@ function pick(route: string) { ); } +// The studio and the library are the two screens worth a boot: ~20MB of CanvasKit +// under one and the whole catalogue under the other. Both used to be mounted for +// every visit, which is the 20MB and the read on a page that was shown neither, +// and it is what a machine short of memory took for a crash. A route mounts its +// screen the first time it is asked for and stays mounted after that, so coming +// back from the studio is a screen opening rather than a page loading. +const heavy = (route: string) => (route.startsWith('/app') ? 'app' : route.startsWith('/library') ? 'library' : 'other'); + function Shell() { const [route, setRoute] = useState(() => window.location.pathname + window.location.search); @@ -64,29 +72,41 @@ function Shell() { }; }, []); - const isApp = route.startsWith('/app'); - const isLibrary = route.startsWith('/library'); - const isOther = !isApp && !isLibrary; + const kind = heavy(route); + const [seen, setSeen] = useState(() => new Set([kind])); + useEffect(() => { + setSeen((prev) => (prev.has(kind) ? prev : new Set(prev).add(kind))); + }, [kind]); + + // One window at a time, decided before either heavy screen is mounted — see + // `useSingleWindow`. `null` is the question still being out: nothing is drawn + // for it, because drawing the app and then taking it away is worse than the + // fraction of a second the answer takes. + const { held, taken, take } = useSingleWindow(kind !== 'other'); + const blocked = kind !== 'other' && held !== false; return ( <> -
- -
-
- -
- {isOther ? ( - route.startsWith('/admin') ? ( - - ) : route.startsWith('/photos') ? ( - - ) : route.startsWith('/profile') ? ( - - ) : ( - - ) - ) : null} + {blocked ? ( + held ? ( + + ) : null + ) : ( + <> + {seen.has('app') ? ( +
+ +
+ ) : null} + {seen.has('library') ? ( +
+ +
+ ) : null} + {kind === 'other' ? page(route) : null} + + )} + {taken ? : null} ); } @@ -100,11 +120,14 @@ installTracking(); // No StrictMode: it double-invokes effects, which would load CanvasKit twice and // run the render pipeline twice on every mount. The install offer hangs off the -// i18n provider because its copy comes from the dictionaries. +// i18n provider because its copy comes from the dictionaries. The boundary is +// the whole app's: an error on the way to the screen used to be a white page. createRoot(root).render( - + + + diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 8ceebfc..13d8116 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1284,10 +1284,16 @@ form[data-key='immich-modal'] { inside its own panel, so the page itself has no scrollbar to hide the strip below the fold. Scoped to `.lib-root` — /admin shares the .adm-* blocks and still scrolls as a page. */ -.lib-root { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding-bottom: 12px; } +.lib-root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .lib-root .adm-frame { flex: 1 1 auto; min-height: 0; } .lib-root .adm-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .lib-root .adm-note { flex: 0 0 auto; } +/* The one line of word a reading has: what it scanned, what it repaired, what it + is doing now. It sits at the foot of the column and is always there, so the + frame above it does not move when a note comes and goes — hence the fixed + height, and `:empty` hiding the ink rather than the row. */ +.lib-root .lib-status { flex: 0 0 auto; min-height: 18px; margin: 6px 0 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lib-root .lib-status:empty { visibility: hidden; } /* The column: title and the folder's own chips stay put, the split takes what is left, the strip keeps its natural height on the bottom. */ .lib-root .adm-col { flex: 1 1 auto; min-height: 0; } @@ -2185,3 +2191,23 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- @media (max-width: 860px) { .offline-bar { bottom: calc(72px + env(safe-area-inset-bottom)); } } + +/* A screen that threw: the sentence and the stack, so the next report carries + what went wrong instead of a blank page. */ +.crash-msg { white-space: pre-wrap; overflow: auto; max-height: 40vh; font-size: 11px; } + +/* Another tab took the roll while this one was working on it. A strip across the + top rather than a dialog: it says what happened and offers the roll back, and + the screen underneath stays readable and usable as it was. */ +.taken-bar { + position: fixed; + inset: 0 0 auto 0; + z-index: 50; + display: flex; + gap: 10px; + align-items: center; + padding: 4px 12px; + background: #111; + color: #fff; + font-size: 12px; +} diff --git a/docker/frontend/src/ui/ErrorBoundary.tsx b/docker/frontend/src/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..a122395 --- /dev/null +++ b/docker/frontend/src/ui/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { t } from '../i18n/I18nProvider'; + +// The app had nothing between a throw and the floor: every error on the way to +// the screen was a white page, and a white page is what the reader reports as a +// crash. This one keeps the sentence and the stack on screen, so the next report +// is something to read instead of something to guess at. +// +// A class because React has no hook for this, and no dependency because the +// whole of it is these twenty lines. +export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('[recipescam]', error, info.componentStack); + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + return ( +
+

{t('crash.title')}

+

{t('crash.body')}

+
{String(error.stack || error.message || error)}
+ +
+ ); + } +} diff --git a/docker/frontend/src/ui/SingleTab.tsx b/docker/frontend/src/ui/SingleTab.tsx new file mode 100644 index 0000000..7c2a937 --- /dev/null +++ b/docker/frontend/src/ui/SingleTab.tsx @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { t } from '../i18n/I18nProvider'; + +// One RecipesCam at a time. Two windows are two walks of the same disk, two +// copies of the catalogue in memory, and — when the two are different builds — +// a second tab left waiting on a database version the first one holds open, +// which on screen is an app that simply never loads. So the window that is up +// says so, and the new one asks before it loads anything: the studio's 20MB of +// wasm and the whole catalogue both hang off this answer. +// +// Two channels, because neither is enough alone: `localStorage` is the record +// that outlives any single reply, and the broadcast is how a window still there +// is told apart from one that died mid-walk and left the record standing. +const KEY = 'recipescam-window'; +const BUS = 'recipescam-windows'; +// How often the window in charge says it is still there, how long a record is +// believed without a fresh beat, and how long a new window waits for the answer +// to its own question before it decides the record was left by a dead window. +const BEAT_MS = 2000; +const TRUST_MS = 6000; +const ANSWER_MS = 400; + +interface Beat { + id: string; + at: number; +} + +function readBeat(): Beat | null { + try { + const raw = localStorage.getItem(KEY); + const beat = raw ? (JSON.parse(raw) as Beat) : null; + return beat && typeof beat.id === 'string' && typeof beat.at === 'number' ? beat : null; + } catch { + // A storage that will not answer is a storage with nothing to say: this + // window is on its own, which is what the first one always is. + return null; + } +} + +export interface SingleWindow { + // Another window holds the pen: this one draws the notice and loads nothing. + // `null` while the question is still out — the heavy screens wait for the + // answer rather than mounting and then being taken away. + held: boolean | null; + // This window was the one in charge and a newer one has claimed the pen. It is + // not reloaded — it is still the reader's work — it is only told. + taken: boolean; + take: () => void; +} + +// `active` is whether this window is standing on one of the two heavy screens. +// The landing page is a page and not a session: it neither holds the pen nor +// asks for it, so a reader with the tab on the landing page is not a reader +// keeping anybody out of the studio. +export function useSingleWindow(active: boolean): SingleWindow { + const id = useRef(Math.random().toString(36).slice(2)); + const owner = useRef(false); + const [held, setHeld] = useState(null); + const [taken, setTaken] = useState(false); + + const claim = useCallback((bus?: BroadcastChannel | null) => { + owner.current = true; + try { + localStorage.setItem(KEY, JSON.stringify({ id: id.current, at: Date.now() } satisfies Beat)); + } catch { + // No storage to claim in, and no second window to be told about. + } + bus?.postMessage({ k: 'claim', id: id.current }); + }, []); + + useEffect(() => { + if (!active) { + owner.current = false; + return; + } + setHeld(null); + let bus: BroadcastChannel | null = null; + try { + bus = new BroadcastChannel(BUS); + } catch { + bus = null; + } + let answered = false; + + const onMessage = (e: MessageEvent) => { + const m = e.data as { k?: string; id?: string } | null; + if (!m || !m.k || m.id === id.current) return; + if (m.k === 'who') { + if (owner.current) bus?.postMessage({ k: 'here', id: id.current }); + return; + } + if (m.k === 'here') { + answered = true; + setHeld(true); + return; + } + if (m.k === 'claim' && owner.current) { + // Handed over, not reloaded: the app stays where it is, and the reader is + // told which of the two windows the library now belongs to. + owner.current = false; + setTaken(true); + } + }; + bus?.addEventListener('message', onMessage as EventListener); + + const tick = setInterval(() => { + if (!owner.current) return; + try { + localStorage.setItem(KEY, JSON.stringify({ id: id.current, at: Date.now() } satisfies Beat)); + } catch { + // Nothing to keep the record in; the beat is what it is. + } + }, BEAT_MS); + + const leave = () => { + if (!owner.current) return; + owner.current = false; + try { + localStorage.removeItem(KEY); + } catch { + // The record is not this window's to clean up any more. + } + }; + // A reload is this window leaving and coming back as the same app: the record + // goes with it, so the page that comes up does not find itself in charge. + window.addEventListener('pagehide', leave); + + let answer: ReturnType | null = null; + const beat = readBeat(); + if (beat && beat.id !== id.current && Date.now() - beat.at < TRUST_MS) { + bus?.postMessage({ k: 'who', id: id.current }); + answer = setTimeout(() => { + if (!answered) { + claim(bus); + setHeld(false); + } + }, ANSWER_MS); + } else { + claim(bus); + setHeld(false); + } + + return () => { + if (answer) clearTimeout(answer); + clearInterval(tick); + window.removeEventListener('pagehide', leave); + bus?.removeEventListener('message', onMessage as EventListener); + bus?.close(); + }; + }, [active, claim]); + + const take = useCallback(() => { + claim(null); + setTaken(false); + setHeld(false); + }, [claim]); + + return { held, taken, take }; +} + +// What a second window draws instead of the app. It says where the window that +// has it already is, and gives the reader the one press that puts it here. +export function OtherWindowNotice({ onUse }: { onUse: () => void }) { + return ( +
+

{t('tab.title')}

+

{t('tab.body')}

+
+ + +
+
+ ); +} + +// The line a window that has just been handed the pen reads. Not a warning: two +// windows of this app at once is the thing to avoid, and this is how the reader +// is told which one the library and the scan now belong to. +export function TakenBar({ onTake }: { onTake: () => void }) { + return ( +
+ {t('tab.taken')} + +
+ ); +}