148 lines
5.8 KiB
TypeScript
148 lines
5.8 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 './styles/mobile-library.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 { 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';
|
|
|
|
// 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.
|
|
//
|
|
// 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('/profile') ? (
|
|
<ProfilePage />
|
|
) : (
|
|
<Landing />
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
|
|
useEffect(() => {
|
|
const onClick = (e: MouseEvent) => {
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) 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();
|
|
go(url.pathname + url.search);
|
|
};
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
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 (
|
|
<>
|
|
{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}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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. 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>
|
|
<ErrorBoundary>
|
|
<Shell />
|
|
</ErrorBoundary>
|
|
<InstallPrompt />
|
|
<OfflineBar />
|
|
</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(() => {});
|
|
});
|
|
}
|