Files
ersatztv/web/src/App.tsx
T
2026-07-15 22:34:19 +02:00

115 lines
3.9 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent
} from 'react';
import { useDashboardHealthQuery } from './api';
import { AppShell } from './app/AppShell';
import { ScreenContent } from './app/ScreenContent';
import {
routeFromLocation,
routeHref,
type ScreenRoute
} from './app/routes';
import {
applyDesignSystemTheme,
getStoredDesignSystemTheme,
type DesignSystemThemeId
} from './designSystem';
import { canLeaveCurrentScreen } from './navigationGuard';
import { PrimaryActionProvider } from './primaryAction';
export function App() {
const [theme, setTheme] = useState<DesignSystemThemeId>(() => getStoredDesignSystemTheme());
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
const healthState = useDashboardHealthQuery();
// The guarded `libraries` route's approved sub-path. App owns this (rather than the wrapper
// self-listening for popstate) so the dirty guard is consulted BEFORE the wrapper switches
// sub-screen (design §D.2, finding 4). Scoped to the libraries route only: the write is gated on
// the arrived route being 'libraries' so Playouts/Media sub-path pops stay byte-identical — they
// still bail App's setActiveRoute via Object.is and own their own pathname.
const [librariesSubPath, setLibrariesSubPath] = useState<string>(() => window.location.pathname);
// The last path the guard has approved us being on. Browser Back/Forward (popstate) cannot be
// cancelled, so when the dirty guard vetoes a pop we re-push THIS path to undo the browser's URL
// change. Kept in a ref (not state) so the popstate handler reads it synchronously at event time.
const currentPathRef = useRef<string>(window.location.pathname);
useEffect(() => {
applyDesignSystemTheme(theme);
}, [theme]);
useEffect(() => {
// popstate covers both real Back/Forward and the synthetic pop navigateToPath() dispatches. If a
// screen has registered a dirty guard and it vetoes, the URL has already moved, so restore the
// last approved path and leave activeRoute untouched.
const onPopState = () => {
if (!canLeaveCurrentScreen()) {
window.history.pushState(null, '', currentPathRef.current);
return;
}
currentPathRef.current = window.location.pathname;
const nextRoute = routeFromLocation();
setActiveRoute(nextRoute);
// Guarded sub-path route: update the wrapper only after navigation is approved. Unguarded
// routes (Playouts/Media) skip this write and keep their screen-owned pathname listeners.
if (nextRoute?.id === 'libraries') {
setLibrariesSubPath(window.location.pathname);
}
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
const route = useMemo(() => activeRoute, [activeRoute]);
const navigate = (nextRoute: ScreenRoute, event: MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {
return;
}
event.preventDefault();
if (nextRoute.id === route?.id) {
return;
}
if (!canLeaveCurrentScreen()) {
return;
}
window.history.pushState(null, '', routeHref(nextRoute));
currentPathRef.current = window.location.pathname;
setActiveRoute(nextRoute);
// A sidebar click into Libraries always targets the hub, so clear any previously-owned editor path.
if (nextRoute.id === 'libraries') {
setLibrariesSubPath(window.location.pathname);
}
};
return (
<PrimaryActionProvider>
<AppShell
healthState={healthState}
onNavigate={navigate}
onThemeChange={setTheme}
route={route}
theme={theme}
>
<ScreenContent
healthState={healthState}
librariesSubPath={librariesSubPath}
route={route}
/>
</AppShell>
</PrimaryActionProvider>
);
}