119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
import { lazy, Suspense, useEffect, type ReactNode } from "react";
|
|
import { Route, Routes } from "react-router";
|
|
import { useApi } from "./api/context";
|
|
import { startClockTicker, syncClock } from "./clock/clock";
|
|
import { AwaySummary } from "./components/AwaySummary";
|
|
import { DealModal } from "./components/DealModal";
|
|
import { formatCredits } from "./format";
|
|
import { tabAttention } from "./layout/attention";
|
|
import { Layout } from "./layout/Layout";
|
|
import { SCREENS } from "./routes";
|
|
import { Contracts } from "./screens/Contracts";
|
|
import { Fleet } from "./screens/Fleet";
|
|
import { HQ } from "./screens/HQ";
|
|
import { Locations } from "./screens/Locations";
|
|
import { Market } from "./screens/Market";
|
|
import { Pilots } from "./screens/Pilots";
|
|
import { Settings } from "./screens/Settings";
|
|
import { useMotionPreference } from "./state/preferences";
|
|
import { StartScreen } from "./screens/StartScreen";
|
|
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
|
import { Button } from "./ui";
|
|
|
|
// The map pulls in PixiJS; load it only when the Map tab is opened.
|
|
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
|
|
|
|
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
|
"/": <HQ />,
|
|
"/map": (
|
|
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
|
<MapScreen />
|
|
</Suspense>
|
|
),
|
|
"/contracts": <Contracts />,
|
|
"/fleet": <Fleet />,
|
|
"/pilots": <Pilots />,
|
|
"/market": <Market />,
|
|
"/locations": <Locations />,
|
|
};
|
|
|
|
/** Reads the game clock (the engine's device clock, CR-014), then ticks it every second. */
|
|
function useGameClock() {
|
|
const api = useApi();
|
|
useEffect(() => {
|
|
void syncClock(api);
|
|
return startClockTicker();
|
|
}, [api]);
|
|
}
|
|
|
|
/** The whole client. Router, API and query providers come from the caller. */
|
|
export function App() {
|
|
useGameClock();
|
|
useMotionPreference();
|
|
const state = usePlayerState();
|
|
useRefetchOnArrival(state.data ?? null, state.refetch);
|
|
const retry = () => void state.refetch();
|
|
|
|
if (state.isPending) {
|
|
return (
|
|
<p className="grid min-h-dvh place-items-center font-display tracking-[0.3em] text-ink-400 uppercase">
|
|
Loading…
|
|
</p>
|
|
);
|
|
}
|
|
if (state.data === undefined) {
|
|
// The first load failed: nothing to show yet.
|
|
return (
|
|
<div role="alert" className="grid min-h-dvh place-content-center gap-4 p-6 text-center">
|
|
<p className="font-display text-lg text-rose-300">Could not load the game.</p>
|
|
<Button onClick={retry} disabled={state.isFetching}>
|
|
{state.isFetching ? "Retrying…" : "Retry"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
if (state.data === null) return <StartScreen />;
|
|
|
|
// A later refresh failed: keep showing the last state, with a way to retry.
|
|
const banner = state.isError ? (
|
|
<div
|
|
role="alert"
|
|
className="flex items-center justify-between gap-2 border-b border-rose-500/40 bg-rose-950/80 px-4 py-2 text-sm text-rose-200 backdrop-blur"
|
|
>
|
|
<span>Couldn't refresh the game.</span>
|
|
<Button variant="danger" size="sm" onClick={retry}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<>
|
|
<Routes>
|
|
<Route
|
|
element={
|
|
<Layout
|
|
credits={formatCredits(state.data.company.credits)}
|
|
banner={banner}
|
|
attention={tabAttention(state.data)}
|
|
/>
|
|
}
|
|
>
|
|
{SCREENS.map((screen) => (
|
|
<Route
|
|
key={screen.path}
|
|
index={screen.path === "/"}
|
|
path={screen.path === "/" ? undefined : screen.path}
|
|
element={SCREEN_COMPONENTS[screen.path]}
|
|
/>
|
|
))}
|
|
{/* Settings opens from the header gear, not a tab (CR-011). */}
|
|
<Route path="/settings" element={<Settings />} />
|
|
</Route>
|
|
</Routes>
|
|
<AwaySummary />
|
|
<DealModal />
|
|
</>
|
|
);
|
|
}
|