web: keep reading a roll while the studio is up
The catalogue hands the visitor to the studio with a plain `<a href>`, and the app has no router: following it threw the page away, so a scan that was halfway through a roll died with it. A scan belongs to the tab, not to the screen that started it. The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`, watched by whoever is up), so the catalogue can unmount and come back to a reading that never stopped; a screen that returns joins it and sees the toolbar line, the stop button and the rows filling in. The internal links are taken over for a history push *only while a scan is in flight* — everywhere else the browser navigates exactly as before, so the landing-to-studio flow is untouched. `scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time off a slow server, handed to the studio mid-scan, back to the catalogue, and the whole roll read to the end with the studio up, counting documents along the way.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import {
|
||||
canBrowseFolders,
|
||||
ensurePermission,
|
||||
getPhoto,
|
||||
jumpScan,
|
||||
listEditedIds,
|
||||
listFolders,
|
||||
listPhotos,
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
readPhotoFile,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
scanFolder,
|
||||
scanSession,
|
||||
startScan,
|
||||
stopScan,
|
||||
watchScan,
|
||||
type LibraryDir,
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
type ScanProgress,
|
||||
} from './engine/library';
|
||||
|
||||
// The catalogue screen, laid out like the admin's picture manager: the tree of
|
||||
@@ -91,18 +94,20 @@ export function Library() {
|
||||
const saved = Number(remembered(COLUMN_KEY));
|
||||
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
|
||||
});
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
// The scan is the tab's, not this screen's — see `scanSession`. This screen is
|
||||
// one of its watchers, and the counter is what tells React the session it is
|
||||
// holding has moved on.
|
||||
const live = scanSession();
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
// When the catalogue was last read back while a scan runs: the refresh reads the
|
||||
// whole catalogue, so it keeps to one every few hundred milliseconds rather than
|
||||
// one per frame.
|
||||
const readAt = useRef(0);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 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.
|
||||
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null);
|
||||
const stopRef = useRef(false);
|
||||
// The folder the reader last clicked, as the walk spells it ('' for the picked
|
||||
// folder, `2026/04/` under it): the scan in flight pulls it to the front of
|
||||
// its queue, so the branch in front of the eye is read first.
|
||||
const jumpRef = useRef<string | null>(null);
|
||||
// Where the drag of the column divider started, and how wide the column was
|
||||
// then — the pointer only ever reports a position, not a distance moved.
|
||||
const grip = useRef<{ x: number; w: number } | null>(null);
|
||||
@@ -117,6 +122,28 @@ export function Library() {
|
||||
setEdited(edits);
|
||||
}, []);
|
||||
|
||||
// A scan runs for as long as the roll takes, and this screen watches it rather
|
||||
// than running it: the studio may be up instead, and the scan carries on. The
|
||||
// names a pass has walked into are drawn as they arrive, a pass ahead of the
|
||||
// frames under them; the catalogue behind the strip is read back on a timer.
|
||||
useEffect(() => {
|
||||
const watching = () => {
|
||||
bump();
|
||||
const progress = scanSession()?.progress;
|
||||
if (!progress) return;
|
||||
setDirs(progress.dirs);
|
||||
if (progress.done === 0 || Date.now() - readAt.current > 700) {
|
||||
readAt.current = Date.now();
|
||||
void reload();
|
||||
}
|
||||
};
|
||||
const unwatch = watchScan(watching);
|
||||
// A scan already in flight when this screen opens is picked up where it is:
|
||||
// the reader who left for the studio comes back to the same roll.
|
||||
watching();
|
||||
return unwatch;
|
||||
}, [reload]);
|
||||
|
||||
// The catalogue outlives the tab; the permission to read those folders does
|
||||
// not. Asking is a quiet prompt, and a refusal leaves the folder listed with
|
||||
// a button that asks again on a click.
|
||||
@@ -176,36 +203,14 @@ export function Library() {
|
||||
|
||||
const scan = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
stopRef.current = false;
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
// The column grows while the roll is read: the folders the walk has named
|
||||
// are on screen a pass before the frames under them. A refresh reads the
|
||||
// whole catalogue, so it keeps to one every few hundred milliseconds
|
||||
// rather than one per frame.
|
||||
let last = 0;
|
||||
const result = await scanFolder(
|
||||
folder,
|
||||
(p) => {
|
||||
setProgress(p);
|
||||
setDirs(p.dirs);
|
||||
if (p.done === 0 || Date.now() - last > 700) {
|
||||
last = Date.now();
|
||||
void reload();
|
||||
}
|
||||
},
|
||||
() => stopRef.current,
|
||||
() => jumpRef.current
|
||||
);
|
||||
const result = await startScan(folder);
|
||||
setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total }));
|
||||
setDirs(result.dirs);
|
||||
await reload();
|
||||
} catch {
|
||||
setNote(t('lib.failed'));
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[reload, t]
|
||||
@@ -415,11 +420,11 @@ export function Library() {
|
||||
same row and ellipsises — the catalogue is the content, this bar
|
||||
is furniture. */}
|
||||
<div className="chip-row lib-bar">
|
||||
<button type="button" className="btn primary" data-key="lib-add" disabled={busy} onClick={() => void add()}>
|
||||
<button type="button" className="btn primary" data-key="lib-add" disabled={!!live} onClick={() => void add()}>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
{progress ? (
|
||||
<button type="button" className="btn" data-key="lib-stop" onClick={() => (stopRef.current = true)}>
|
||||
{live ? (
|
||||
<button type="button" className="btn" data-key="lib-stop" onClick={stopScan}>
|
||||
{t('lib.stop')}
|
||||
</button>
|
||||
) : null}
|
||||
@@ -427,7 +432,7 @@ export function Library() {
|
||||
{t('lib.hint')}
|
||||
</span>
|
||||
<span className="hint" data-key="lib-progress">
|
||||
{progress ? t('lib.scanning', { done: progress.done, total: progress.total, added: progress.added }) : ''}
|
||||
{live ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) : ''}
|
||||
</span>
|
||||
<span className="adm-spacer" />
|
||||
{folders.length > 0 ? (
|
||||
@@ -508,7 +513,7 @@ export function Library() {
|
||||
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
|
||||
// A roll being read says so on its own row: while a scan runs
|
||||
// the toolbar's progress line is nowhere near the eye.
|
||||
const scanning = !!folder && progress?.folder === folder.name;
|
||||
const scanning = !!folder && live?.folder === folder.name;
|
||||
const under = parents.has(n.key);
|
||||
return (
|
||||
<button
|
||||
@@ -528,7 +533,7 @@ export function Library() {
|
||||
setNode(n.key);
|
||||
// The folder just clicked is the one a scan in flight
|
||||
// reads next, whatever layer it sits in.
|
||||
jumpRef.current = relOf(n.key);
|
||||
jumpScan(relOf(n.key));
|
||||
if (!under) return;
|
||||
setFolded((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -700,7 +705,7 @@ export function Library() {
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-menu-add"
|
||||
disabled={busy}
|
||||
disabled={!!live}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void add();
|
||||
@@ -725,7 +730,7 @@ export function Library() {
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-rescan-${menu.folder.name}`}
|
||||
disabled={busy}
|
||||
disabled={!!live}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void scan(menu.folder!);
|
||||
@@ -750,7 +755,7 @@ export function Library() {
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-reconnect-${menu.folder.name}`}
|
||||
disabled={busy}
|
||||
disabled={!!live}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void reconnect(menu.folder!);
|
||||
|
||||
@@ -368,6 +368,81 @@ export async function scanFolder(
|
||||
return progress;
|
||||
}
|
||||
|
||||
// --- the scan in flight ----------------------------------------------------
|
||||
|
||||
// A scan belongs to the tab, not to the screen it was started from. A roll takes
|
||||
// minutes, and the reader who started it has every reason to go to the studio and
|
||||
// work on a frame while it runs — so the screen that started it may well be gone
|
||||
// before the scan is. What that screen would have held lives here instead: one
|
||||
// scan at a time, watched by whichever screen is up.
|
||||
export interface ScanSession {
|
||||
// The folder being read, by name.
|
||||
folder: string;
|
||||
progress: ScanProgress;
|
||||
// What the reader has asked for since: stop, or read this folder next (the path
|
||||
// as `rollWalk` spells it — `''` for the picked folder itself).
|
||||
stop: boolean;
|
||||
jump: string | null;
|
||||
}
|
||||
|
||||
let live: ScanSession | null = null;
|
||||
const watchers = new Set<() => void>();
|
||||
|
||||
const tell = () => {
|
||||
for (const fn of watchers) fn();
|
||||
};
|
||||
|
||||
// The scan in flight, or null. The same object for the whole of the scan, with
|
||||
// `progress` replaced on every pass — watchers are told, so they re-read it.
|
||||
export function scanSession(): ScanSession | null {
|
||||
return live;
|
||||
}
|
||||
|
||||
// Watch, and get the unsubscribe back: the shape an effect already has.
|
||||
export function watchScan(fn: () => void): () => void {
|
||||
watchers.add(fn);
|
||||
return () => {
|
||||
watchers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function stopScan(): void {
|
||||
if (!live) return;
|
||||
live.stop = true;
|
||||
tell();
|
||||
}
|
||||
|
||||
export function jumpScan(rel: string | null): void {
|
||||
if (live) live.jump = rel;
|
||||
}
|
||||
|
||||
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
|
||||
// this thread, so a second scan would only slow the first one down. The promise
|
||||
// settles when the scan does — the caller that started it may be long gone.
|
||||
export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
|
||||
if (live) return Promise.reject(new Error('a scan is already running'));
|
||||
const session: ScanSession = {
|
||||
folder: folder.name,
|
||||
progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] },
|
||||
stop: false,
|
||||
jump: null,
|
||||
};
|
||||
live = session;
|
||||
tell();
|
||||
return scanFolder(
|
||||
folder,
|
||||
(p) => {
|
||||
session.progress = p;
|
||||
tell();
|
||||
},
|
||||
() => session.stop,
|
||||
() => session.jump
|
||||
).finally(() => {
|
||||
live = null;
|
||||
tell();
|
||||
});
|
||||
}
|
||||
|
||||
// --- reading ---------------------------------------------------------------
|
||||
|
||||
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/fonts.css';
|
||||
import './styles/tokens.css';
|
||||
@@ -11,6 +12,7 @@ import { Admin } from './Admin';
|
||||
import { ProfilePage } from './ProfilePage';
|
||||
import { PhotosPage } from './PhotosPage';
|
||||
import { Library } from './Library';
|
||||
import { scanSession } from './engine/library';
|
||||
import { InstallPrompt } from './ui/InstallPrompt';
|
||||
import { installTracking } from './track';
|
||||
|
||||
@@ -18,20 +20,59 @@ 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.
|
||||
const path = window.location.pathname;
|
||||
const page = path.startsWith('/app') ? (
|
||||
<Workspace />
|
||||
) : path.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : path.startsWith('/library') ? (
|
||||
<Library />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
<Landing />
|
||||
);
|
||||
function pick(route: string) {
|
||||
return route.startsWith('/app') ? (
|
||||
<Workspace />
|
||||
) : route.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : route.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : route.startsWith('/library') ? (
|
||||
<Library />
|
||||
) : route.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
<Landing />
|
||||
);
|
||||
}
|
||||
|
||||
// Links are plain `<a href>`, so following one throws the page away — fine
|
||||
// everywhere except while a roll is being read: that scan runs for minutes in
|
||||
// this page's memory, and only the studio needs to be reachable from the
|
||||
// catalogue. So the click is taken over *only* while a scan is in flight, and
|
||||
// the browser keeps doing the navigating the rest of the time.
|
||||
function Shell() {
|
||||
const [route, setRoute] = useState(() => window.location.pathname + window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
if (!scanSession()) return;
|
||||
const target = e.target as Element | null;
|
||||
const link = target?.closest?.('a');
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
if (link.target || link.hasAttribute('download')) return;
|
||||
const url = new URL(link.href, window.location.href);
|
||||
const here = window.location.pathname + window.location.search;
|
||||
if (url.origin !== window.location.origin || url.hash) return;
|
||||
if (url.pathname + url.search === here) return;
|
||||
e.preventDefault();
|
||||
history.pushState(null, '', url.pathname + url.search);
|
||||
setRoute(url.pathname + url.search);
|
||||
window.scrollTo(0, 0);
|
||||
};
|
||||
const onPop = () => setRoute(window.location.pathname + window.location.search);
|
||||
document.addEventListener('click', onClick);
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick);
|
||||
window.removeEventListener('popstate', onPop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return pick(route);
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) throw new Error('#root missing');
|
||||
|
||||
@@ -45,7 +86,7 @@ installTracking();
|
||||
createRoot(root).render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
{page}
|
||||
<Shell />
|
||||
<InstallPrompt />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user