fix(app): keep the app on its feet — a boundary, one window, and a reading that ends

- boundary: src/ui/ErrorBoundary.tsx, around the whole app rather than around a
  screen. A throw on the way to the first paint used to be a white page with
  nothing in it to report; it is a sentence, the message and the stack now, with
  a reload beside them, so the next report of a crash carries what went wrong.

- one window: src/ui/SingleTab.tsx holds the app to one tab over localStorage and
  a BroadcastChannel. A second tab on /app or /library is told so and offered the
  roll — "use this tab" takes it, and the tab that held it gets a bar and the
  offer back. A tab holding a reading is a tab the machine has already spent its
  memory on, and a second one beside it is the memory the first will not get.

- mount: the studio and the library are mounted the first time their route is
  asked for and stay mounted after that, not on every visit. Booting CanvasKit
  (~20MB) and reading the whole catalogue on a page that shows neither is the
  read and the memory a machine short of either takes for a crash; a trip back
  from the studio is a screen opening rather than a page loading.

- reading: READ_MS bounds one touch of the disk at 30s. getFile and arrayBuffer
  on a drive that has gone away do not fail, they never settle — the lane waits
  for ever, the reading never ends, and STOP is a button with nothing left to
  read it. A frame given up on is a frame the next reading takes up again.

- reading: the rows of a walk go down a pass at a time, so a tab that never comes
  back leaves the column with the folders this reading walked rather than the ones
  the last reading did.

- reading: a stopped reading hands over the numbers it counted instead of null.
  Handed null it threw its own count away and had noughts written for every folder
  it walked that the file did not already hold, so a folder added to a drive read
  nought until the next scan.

- repair: STOP ends the afternoon. A repair is a loop the button has no handle on,
  so it holds the count of stops it started at and compares between rolls, and
  says it was cut short rather than reporting a run it did not make.

- add: a folder the browser could not name — every drive root on Windows is "\" —
  is asked for one, and a folder whose name is already another folder's is refused
  rather than folded in with it. The record, the frame ids, the walk's position
  and the number the column draws are all keyed by that name.

- wall: four hundred tiles queued ahead is a reader waiting on frames three
  screens below the one they are looking at; sixty is a screenful and the wall
  mints the rest as it scrolls. The catalogue is read back every 30s, not every
  5s — reading it back costs every thumbnail in it.

- library: the note is drawn under the frame rather than above it. A line that
  comes and goes in the flow moved the frame, the column and the strip by its own
  height every time a scan started or ended.

- manifest: "id": "/", so the installed app and the tab are one app.

- checks: tree-scan-check waited for a count of exactly four rows — a state the
  screen does not have. The wait ran out its clock and then measured whatever the
  reading had done since; it waits for the whole tree now.
This commit is contained in:
2026-10-10 17:46:44 +07:00
parent c557108eee
commit bcc24a62c3
10 changed files with 532 additions and 52 deletions
+1
View File
@@ -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",
+7 -2
View File
@@ -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();
+115 -14
View File
@@ -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<Shot | null>): 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<boolean> {
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 }) {
</a>
</header>
{note ? <p className="hint adm-note">{note}</p> : 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 ? (
<div className="adm-frame" data-key="lib-frame">
@@ -2192,7 +2282,12 @@ function Catalogue({ admin }: { admin: boolean }) {
if (folder) void scan(folder, false);
}}
>
<span className="chip-label">{t('lib.resume', { folder: resume })}</span>
<span className="chip-label">
{/* `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 })}
</span>
</button>
) : null}
<span className="adm-spacer" />
@@ -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. */}
<p className="hint lib-status" data-key="lib-status" aria-live="polite" title={note ?? undefined}>{note}</p>
</div>
);
}
+64 -6
View File
@@ -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<T>(work: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((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<void> => {
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<boolean> {
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();
+18
View File
@@ -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)',
};
+20
View File
@@ -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;
+52 -29
View File
@@ -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') ? (
<Workspace />
) : 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') ? (
<Admin />
) : route.startsWith('/photos') ? (
<PhotosPage />
) : route.startsWith('/library') ? (
<Library />
) : route.startsWith('/profile') ? (
<ProfilePage />
) : (
@@ -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 (
<>
<div style={{ display: isApp ? 'block' : 'none', height: '100%' }}>
<Workspace />
</div>
<div style={{ display: isLibrary ? 'block' : 'none', height: '100%' }}>
<Library />
</div>
{isOther ? (
route.startsWith('/admin') ? (
<Admin />
) : route.startsWith('/photos') ? (
<PhotosPage />
) : route.startsWith('/profile') ? (
<ProfilePage />
) : (
<Landing />
)
) : null}
{blocked ? (
held ? (
<OtherWindowNotice onUse={take} />
) : null
) : (
<>
{seen.has('app') ? (
<div style={{ display: kind === 'app' ? 'block' : 'none', height: '100%' }}>
<Workspace />
</div>
) : null}
{seen.has('library') ? (
<div style={{ display: kind === 'library' ? 'block' : 'none', height: '100%' }}>
<Library />
</div>
) : null}
{kind === 'other' ? page(route) : null}
</>
)}
{taken ? <TakenBar onTake={take} /> : 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(
<ThemeProvider>
<I18nProvider>
<Shell />
<ErrorBoundary>
<Shell />
</ErrorBoundary>
<InstallPrompt />
<OfflineBar />
</I18nProvider>
+27 -1
View File
@@ -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;
}
+36
View File
@@ -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 (
<div className="adm" style={{ padding: 24, maxWidth: 760, margin: '10vh auto' }}>
<h2 className="adm-sub">{t('crash.title')}</h2>
<p className="hint">{t('crash.body')}</p>
<pre className="hint crash-msg">{String(error.stack || error.message || error)}</pre>
<button type="button" className="btn primary" data-key="crash-reload" onClick={() => window.location.reload()}>
{t('crash.reload')}
</button>
</div>
);
}
}
+192
View File
@@ -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<boolean | null>(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<typeof setTimeout> | 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 (
<div className="adm" style={{ padding: 24, maxWidth: 620, margin: '12vh auto' }}>
<h2 className="adm-sub">{t('tab.title')}</h2>
<p className="hint">{t('tab.body')}</p>
<div className="chip-row">
<button type="button" className="btn primary" data-key="tab-use-here" onClick={onUse}>
{t('tab.here')}
</button>
<button type="button" className="btn" data-key="tab-close" onClick={() => window.close()}>
{t('tab.close')}
</button>
</div>
</div>
);
}
// 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 (
<div className="taken-bar" role="status">
<span>{t('tab.taken')}</span>
<button type="button" className="btn" data-key="tab-retake" onClick={onTake}>
{t('tab.retake')}
</button>
</div>
);
}