112 lines
5.1 KiB
TypeScript
112 lines
5.1 KiB
TypeScript
'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<boolean | null>(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 (
|
|
<div className="app-shell">
|
|
<p data-testid="auth-checking" className="sign-in-loading">
|
|
Loading…
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
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 <SignInScreen onAuthed={() => setProbeKey((k) => k + 1)} />;
|
|
}
|
|
|
|
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
|
return <AppShell onLogout={() => 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<HoverTarget | null>(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<typeof state>(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 (
|
|
<div className="app-shell">
|
|
{/* 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 */}
|
|
<LeftPane state={state} loading={loading} error={error} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
|
{/* 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 */}
|
|
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} isAdmin={isAdmin} />
|
|
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
|
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
|
</div>
|
|
);
|
|
}
|