79b0d0db86
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.
105 lines
4.0 KiB
TypeScript
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(() => {});
|
|
});
|
|
}
|