Files
RecipesCam/docker/frontend/src/main.tsx
T
3dtours 79b0d0db86 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.
2026-09-28 20:28:00 +07:00

105 lines
4.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/fonts.css';
import './styles/tokens.css';
import './styles/app.css';
import './styles/landing.css';
import { ThemeProvider } from './theme/ThemeProvider';
import { I18nProvider } from './i18n/I18nProvider';
import { Landing } from './Landing';
import { Workspace } from './App';
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';
// Six routes, no router: the landing page, the workspace, the strip moderation
// 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') ? (
<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');
// The traffic counter records this page and then any click on a `data-key`
// control, for the life of the page.
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.
createRoot(root).render(
<ThemeProvider>
<I18nProvider>
<Shell />
<InstallPrompt />
</I18nProvider>
</ThemeProvider>
);
// Service worker, production only: in dev it would answer the browser with the
// last build's shell instead of vite's modules. Registered after `load` so its
// precache does not race the studio's own 20MB of wasm for the connection, and a
// browser that refuses it (private mode, a plain-http origin) just loses the
// offline shell — nothing else depends on it.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}