import { createRoot } from "react-dom/client";
import { Suspense, use, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react";
import { createBrowserRouter, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom";
import type { AuthUser } from "@stc/contracts";
import { AppShell, installRouteScroll, LoadingLine, routeScroll } from "@stc/ui";
import { webFeatures } from "./features";
import { visibleNav } from "./nav";
function Home() {
return
No features installed yet
;
}
/** The signed-in user (GET /api/me), refreshed on navigation so sign-in and sign-out update the nav. */
function useSessionUser(): AuthUser | null {
const { pathname } = useLocation();
const [user, setUser] = useState(null);
useEffect(() => {
let live = true;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : { user: null }))
.then((b: { user: AuthUser | null }) => { if (live) setUser(b.user ?? null); })
.catch(() => { if (live) setUser(null); });
return () => { live = false; };
}, [pathname]);
return user;
}
/** Scroll per history entry: top on a new navigation, the saved position on back/forward. */
function useRouteScroll() {
const loc = useLocation();
const type = useNavigationType();
useEffect(() => installRouteScroll(), []);
useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]);
}
/**
* After a navigation, the new page's heading (its first h1) is announced in a polite live region once it renders,
* and takes focus (tabindex -1), unless the navigation came from the dock or the course outline: those controls
* stay put, so focus stays on the one that was used (Enter on Next twice goes two lessons on). The first page load
* keeps the browser's own focus.
*/
function useRouteFocus(): string {
const loc = useLocation();
const [said, setSaid] = useState("");
const last = useRef(null);
const first = useRef(true);
useEffect(() => {
const main = document.querySelector(".stc-main");
if (!main) return;
const initial = first.current;
first.current = false;
const take = () => {
const h = main.querySelector("h1");
if (!h || h === last.current) return false;
last.current = h;
if (initial) return true;
setSaid(h.textContent ?? "");
if (document.activeElement?.closest(".stc-dock, .lr-side")) return true;
if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1");
h.focus({ preventScroll: true });
return true;
};
if (take()) return;
const mo = new MutationObserver(() => { if (take()) mo.disconnect(); });
mo.observe(main, { childList: true, subtree: true });
const stop = setTimeout(() => mo.disconnect(), 10_000);
return () => { mo.disconnect(); clearTimeout(stop); };
}, [loc.key]);
return said;
}
function Layout() {
useRouteScroll();
const said = useRouteFocus();
// Links the user cannot use are hidden; the pages and APIs still enforce access themselves.
const nav = visibleNav(webFeatures, useSessionUser());
const header = (
<>
STCourses
{nav.length ? (
) : null}
>
);
return (
<>
}>
{said}
>
);
}
/**
* A route component loaded on first use, like React.lazy, but preloadable: once one route of a feature has
* rendered, the feature's other routes load straight after. Moving between them (course page to lesson) then never
* suspends, so the course sidebar is not dropped for a frame on the first visit.
*/
function lazyRoute(load: () => Promise<{ default: ComponentType }>, siblings: (() => unknown)[]) {
let Loaded: ComponentType | null = null;
let pending: Promise | null = null;
const preload = () => (pending ??= load().then((m) => { Loaded = m.default; }));
function Route() {
if (!Loaded) use(preload());
useEffect(() => { siblings.forEach((p) => void p()); }, []);
const C = Loaded!;
return ;
}
return { Route, preload };
}
const featureRoutes: RouteObject[] = webFeatures.flatMap((f) => {
const siblings: (() => unknown)[] = [];
return f.routes.map((r) => {
const { Route, preload } = lazyRoute(r.element, siblings);
siblings.push(preload);
return { path: r.path, element: };
});
});
const router = createBrowserRouter([
{ element: , children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: }])] },
]);
// Navigation swaps at once (no View Transitions, no holding the old page, ruling R19); the new content region
// plays its CSS entrance (@stc/ui tokens.css) and a loading line shows if code or data takes over 150 ms.
createRoot(document.getElementById("root")!).render();