'use client'; import dynamic from 'next/dynamic'; import { useEffect, useRef, useState } from 'react'; import LeftPane from '@/components/LeftPane'; import RightPane from '@/components/RightPane'; import SignInScreen from '@/components/SignInScreen'; import ToastStack from '@/components/Toasts'; import type { HoverTarget } from '@/components/hoverTypes'; import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient'; import { useGameState } from '@/lib/useGameState'; import { useToasts } from '@/lib/useToasts'; import { diffSnapshots } from '@/lib/deriveEvents'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); export default function Home() { // v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either // the sign-in screen or the game shell. The shell only mounts once authenticated, so its state // feed never polls an unauthenticated /api/state. const [authed, setAuthed] = useState(null); // v1.10.0 (ISS-158): /api/me's `admin` flag — gates the top-bar Admin button in Map. const [isAdmin, setIsAdmin] = useState(false); // Bumped after a form sign-in so the probe below re-runs for the NEW session — isAdmin must // track whoever just signed in, not the user probed on mount (or before the last logout). const [probeKey, setProbeKey] = useState(0); useEffect(() => { const sid = storedSession(); if (!sid) { setAuthed(false); return; } let cancelled = false; fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' }) .then(async (res) => { if (cancelled) return; if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in else setIsAdmin(Boolean(((await res.json()) as { admin?: boolean }).admin)); // v1.10.0 (ISS-158) setAuthed(res.ok); }) .catch(() => { if (!cancelled) setAuthed(false); // network error → let the user try to sign in }); return () => { cancelled = true; }; }, [probeKey]); if (authed === null) { return (

Loading…

); } if (!authed) { // v1.10.0 (ISS-158): re-probe /api/me for the new session instead of blindly flipping authed — // isAdmin has to come from the user who just signed in. return setProbeKey((k) => k + 1)} />; } // The admin panel's Log out cleared the session — drop back to sign-in (ISS-107). return setAuthed(false)} isAdmin={isAdmin} />; } function AppShell({ onLogout, isAdmin }: { onLogout: () => void; isAdmin: boolean }) { // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // lets a pane push an action endpoint's full response snapshot in immediately. const { state, loading, error, applySnapshot } = useGameState(1000); // ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in // either pane highlights the matching plane/route/airports on the map. const [hovered, setHovered] = useState(null); // ISS-008 (v0.17.0): bottom-right toasts — action errors from both panes plus game events // derived by diffing consecutive snapshots (no server event feed exists). const { toasts, push, dismiss } = useToasts(); const onError = (message: string) => push('error', message); // Diff each new snapshot against the previous one → info toasts. When the window is hidden // and notifications are granted, mirror them as browser notifications too. const prevSnapshotRef = useRef(null); useEffect(() => { if (state === null) return; for (const event of diffSnapshots(prevSnapshotRef.current, state)) { push('info', event.message); if (document.hidden && 'Notification' in window && Notification.permission === 'granted') { new Notification('Flight Ops', { body: event.message }); } } prevSnapshotRef.current = state; }, [state, push]); return (
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's fly/abandon actions push their response snapshot into the shared state */} {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its action response into the shared state, exactly like RightPane does */}
); }