Full-screen map with draggable, collapsible floating HUD windows
- MapLibre map is now a fixed, full-viewport background layer (z-0) - New FloatingWindow: drag by title bar (pointer capture, viewport-clamped, position persisted to localStorage), collapsible to title bar, bring-to-front z-index; semi-transparent slate window with amber/cyan accents - Game screen re-laid out as floating windows over the map: slim top header (scenario, turn + date, player nation chip, Idle/Simulating status, Focus map collapse-all), dossier (top-left), event feed (right), Your move deck (bottom-center), legend (bottom-left), tiny zoom control (bottom-right) - MapLibre default nav controls removed; custom floating zoom-in/out control - Panels gain an inWindow mode (no card chrome, same testids) - server: honor HISTORY_API_PORT as PORT fallback (dev proxy/e2e agreement) - css: keep maplibre canvas base color and Radix portals above HUD windows
This commit is contained in:
+292
-105
@@ -1,13 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CircleDot, Loader2, Radar } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
BookOpen,
|
||||
CircleDot,
|
||||
ListTree,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
Minus,
|
||||
Plus,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Badge } from './components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
||||
import { cn } from './lib/utils';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import WorldMap, { type MapApi } from './components/WorldMap';
|
||||
import FloatingWindow from './components/FloatingWindow';
|
||||
import MapLegend from './components/MapLegend';
|
||||
import NationPanel from './components/NationPanel';
|
||||
import EventFeed from './components/EventFeed';
|
||||
import ActionPanel from './components/ActionPanel';
|
||||
@@ -32,6 +45,39 @@ function toastError(e: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating-window collapse persistence (positions live in FloatingWindow itself).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FLOAT_COLLAPSED_KEY = 'history.floatwin.v1.collapsed';
|
||||
|
||||
/** Every collapsible HUD panel (the slim header is never collapsed). */
|
||||
const PANEL_WINDOW_IDS = ['dossier', 'feed', 'action', 'legend', 'zoom'] as const;
|
||||
|
||||
function loadCollapsedMap(): Record<string, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLOAT_COLLAPSED_KEY);
|
||||
if (raw == null) return {};
|
||||
const v: unknown = JSON.parse(raw);
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
||||
if (typeof val === 'boolean') out[k] = val;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistCollapsedMap(m: Record<string, boolean>): void {
|
||||
try {
|
||||
localStorage.setItem(FLOAT_COLLAPSED_KEY, JSON.stringify(m));
|
||||
} catch {
|
||||
/* storage unavailable — collapse state just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||
@@ -216,11 +262,15 @@ interface GameScreenProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The main play view — an operations console. A top status bar carries the
|
||||
* scenario, the current turn + date, the player nation (color-chipped), and a
|
||||
* live idle/simulating indicator. Below it, a three-column grid: the nation
|
||||
* dossier (left), the dominant map (center), and the command deck + event feed
|
||||
* (right). On small screens these stack map-first.
|
||||
* The main play view — a full-bleed world map with a floating HUD. The MapLibre
|
||||
* map is a fixed, full-viewport background (z-0); over it float draggable,
|
||||
* collapsible windows: a slim header bar (scenario, turn + date, player nation,
|
||||
* live status, "Focus map" collapse-all) at the top, the nation dossier
|
||||
* (top-left), the event feed (right), the "Your move" command deck
|
||||
* (bottom-center), the nation-color legend (bottom-left), and a tiny map zoom
|
||||
* control (bottom-right). Each window remembers its dragged position, and
|
||||
* clicking one raises it above its siblings. Pan/zoom/click on the map stays
|
||||
* fully available in the gaps between windows.
|
||||
*/
|
||||
function GameScreen({
|
||||
game,
|
||||
@@ -239,115 +289,252 @@ function GameScreen({
|
||||
const viewed = game.nations[viewingId] ?? player;
|
||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Collapse state for the HUD panels, persisted per id (survives reloads).
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsedMap);
|
||||
const setCollapsedFor = useCallback((id: string, c: boolean) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = { ...prev, [id]: c };
|
||||
persistCollapsedMap(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const allCollapsed = PANEL_WINDOW_IDS.every((id) => collapsed[id]);
|
||||
// "Focus map" collapses every panel at once to reveal the bare map; the same
|
||||
// button (now "Show panels") restores them.
|
||||
const toggleAllPanels = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const target = !PANEL_WINDOW_IDS.every((id) => prev[id]);
|
||||
const next = { ...prev };
|
||||
for (const id of PANEL_WINDOW_IDS) next[id] = target;
|
||||
persistCollapsedMap(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Tiny map API (zoom in/out) exposed by WorldMap for the floating control.
|
||||
const mapApiRef = useRef<MapApi | null>(null);
|
||||
const handleMapReady = useCallback((api: MapApi | null) => {
|
||||
mapApiRef.current = api;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-[1600px] flex-col px-3 py-4 sm:px-4 lg:px-6">
|
||||
<header
|
||||
data-testid="game-header"
|
||||
className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3 rounded-xl border bg-card/60 px-4 py-3"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onHome}>
|
||||
← Scenarios
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Radar className="size-5 text-primary" />
|
||||
<div className="leading-tight">
|
||||
<h1 className="text-lg font-bold tracking-tight">History</h1>
|
||||
<p className="text-[11px] text-muted-foreground">{game.presetTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-screen">
|
||||
{/* Full-viewport background map, behind every floating window. */}
|
||||
<WorldMap
|
||||
regions={regions}
|
||||
playerNationId={game.playerNationId}
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Turn</div>
|
||||
<div className="mt-1 text-2xl font-bold tabular-nums" data-testid="turn-count">
|
||||
{game.turn}
|
||||
{/* Slim floating header: brand, turn + date, player nation, live status, Focus map. */}
|
||||
<FloatingWindow
|
||||
id="header"
|
||||
anchor="top-center"
|
||||
collapsible={false}
|
||||
title={
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={onHome} className="shrink-0">
|
||||
← Scenarios
|
||||
</Button>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Radar className="size-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="truncate text-base font-bold tracking-tight">History</div>
|
||||
<div className="truncate text-[11px] font-normal text-muted-foreground">
|
||||
{game.presetTitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">Date</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
}
|
||||
titleExtra={
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Turn
|
||||
</div>
|
||||
<div className="mt-0.5 text-xl font-bold tabular-nums" data-testid="turn-count">
|
||||
{game.turn}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-8 w-px bg-border sm:block" />
|
||||
<div className="text-right leading-none">
|
||||
<div className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Date
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-8 w-px bg-border sm:block" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
|
||||
style={{ background: nationColor(game.playerNationId) }}
|
||||
/>
|
||||
<span className="text-sm font-medium" data-testid="player-nation">
|
||||
{player?.name ?? game.playerNationId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden h-9 w-px bg-border sm:block" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block size-3.5 rounded-full ring-2 ring-white/10"
|
||||
style={{ background: nationColor(game.playerNationId) }}
|
||||
/>
|
||||
<span className="text-sm font-medium" data-testid="player-nation">
|
||||
{player?.name ?? game.playerNationId}
|
||||
data-testid="status-indicator"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
busy
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border bg-muted/40 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{busy ? <Loader2 className="size-3 animate-spin" /> : <CircleDot className="size-3" />}
|
||||
{busy ? 'Simulating…' : 'Idle'}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={toggleAllPanels}
|
||||
data-testid="focus-map"
|
||||
title={allCollapsed ? 'Show all panels' : 'Collapse all panels to reveal the map'}
|
||||
>
|
||||
<MapIcon className="size-3.5" />
|
||||
{allCollapsed ? 'Show panels' : 'Focus map'}
|
||||
</Button>
|
||||
</div>
|
||||
<span
|
||||
data-testid="status-indicator"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
busy ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-muted/40 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<CircleDot className="size-3" />
|
||||
)}
|
||||
{busy ? 'Simulating…' : 'Idle'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Nation dossier, top-left. */}
|
||||
<FloatingWindow
|
||||
id="dossier"
|
||||
anchor="top-left"
|
||||
width={300}
|
||||
title="Dossier"
|
||||
titleIcon={<BookOpen className="size-4" />}
|
||||
collapsed={!!collapsed.dossier}
|
||||
onCollapsedChange={(c) => setCollapsedFor('dossier', c)}
|
||||
bodyClassName="max-h-[56vh] overflow-y-auto"
|
||||
>
|
||||
<div className="border-b border-border/60 p-3">
|
||||
<div className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Inspect
|
||||
</div>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-full" data-testid="inspect-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nations.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</header>
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
<main className="grid flex-1 gap-4 lg:grid-cols-[300px_minmax(0,1fr)_340px]">
|
||||
{/* Left: nation dossier (mobile order 3). */}
|
||||
<section className="order-3 min-w-0 lg:order-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inspect</span>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-full" data-testid="inspect-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nations.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<NationPanel nation={viewed} nations={game.nations} isPlayer={viewingId === game.playerNationId} />
|
||||
</section>
|
||||
{/* Event feed, right side. */}
|
||||
<FloatingWindow
|
||||
id="feed"
|
||||
anchor="right"
|
||||
width={330}
|
||||
title="Event feed"
|
||||
titleIcon={<Activity className="size-4" />}
|
||||
titleExtra={
|
||||
<Badge variant="secondary" className="shrink-0 text-xs tabular-nums">
|
||||
{game.events.length}
|
||||
</Badge>
|
||||
}
|
||||
collapsed={!!collapsed.feed}
|
||||
onCollapsedChange={(c) => setCollapsedFor('feed', c)}
|
||||
bodyClassName="h-[52vh]"
|
||||
>
|
||||
<EventFeed events={game.events} nations={game.nations} inWindow />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Center: the dominant map (mobile order 1). */}
|
||||
<section className="order-1 min-w-0 lg:order-2">
|
||||
<div className="relative h-[58vh] min-h-[420px] overflow-hidden rounded-xl border lg:h-[80vh]">
|
||||
<WorldMap
|
||||
regions={regions}
|
||||
playerNationId={game.playerNationId}
|
||||
nations={game.nations}
|
||||
presetId={game.presetId}
|
||||
onFocusNation={setViewingId}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/* Command deck ("Your move"), bottom-center — the prominent typing spot. */}
|
||||
<FloatingWindow
|
||||
id="action"
|
||||
anchor="bottom-center"
|
||||
width={370}
|
||||
title="Your move"
|
||||
titleIcon={<Radar className="size-4 text-primary" />}
|
||||
titleExtra={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onJump}
|
||||
disabled={busy}
|
||||
data-testid="jump-forward"
|
||||
className="shrink-0"
|
||||
>
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
}
|
||||
collapsed={!!collapsed.action}
|
||||
onCollapsedChange={(c) => setCollapsedFor('action', c)}
|
||||
bodyClassName="max-h-[72vh] overflow-y-auto"
|
||||
>
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
inWindow
|
||||
/>
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Right: command deck + event feed (mobile order 2). */}
|
||||
<aside className="order-2 flex min-h-0 min-w-0 flex-col gap-4 lg:order-3">
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
/>
|
||||
<div className="min-h-[360px] flex-1 lg:min-h-0" data-testid="event-feed-slot">
|
||||
<EventFeed events={game.events} nations={game.nations} />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
{/* Nation-color legend, bottom-left. */}
|
||||
<FloatingWindow
|
||||
id="legend"
|
||||
anchor="bottom-left"
|
||||
width={170}
|
||||
title="Legend"
|
||||
titleIcon={<ListTree className="size-4" />}
|
||||
collapsed={!!collapsed.legend}
|
||||
onCollapsedChange={(c) => setCollapsedFor('legend', c)}
|
||||
>
|
||||
<MapLegend regions={regions} />
|
||||
</FloatingWindow>
|
||||
|
||||
{/* Tiny floating zoom control, bottom-right (clear of the attribution). */}
|
||||
<FloatingWindow
|
||||
id="zoom"
|
||||
anchor="bottom-right"
|
||||
width={88}
|
||||
title="Map"
|
||||
titleIcon={<MapIcon className="size-4" />}
|
||||
offset={{ y: -28 }}
|
||||
collapsed={!!collapsed.zoom}
|
||||
onCollapsedChange={(c) => setCollapsedFor('zoom', c)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5 p-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomIn()}
|
||||
aria-label="Zoom in"
|
||||
data-testid="zoom-in"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => mapApiRef.current?.zoomOut()}
|
||||
aria-label="Zoom out"
|
||||
data-testid="zoom-out"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user