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:
+135
-103
@@ -18,6 +18,12 @@ interface Props {
|
||||
onChat: (to: string, message: string) => void;
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
onJump: () => void;
|
||||
/**
|
||||
* Render without the outer card chrome (the surrounding floating window
|
||||
* provides the border, the "Your move" title, and the "Jump forward" button
|
||||
* in its title bar). Same content, same testids.
|
||||
*/
|
||||
inWindow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +34,16 @@ interface Props {
|
||||
* that turns take a few seconds. A "Jump forward" button auto-simulates the
|
||||
* world with no player move.
|
||||
*/
|
||||
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
|
||||
export default function ActionPanel({
|
||||
game,
|
||||
busy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
inWindow = false,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState('action');
|
||||
const [action, setAction] = useState('');
|
||||
const [target, setTarget] = useState('');
|
||||
@@ -82,110 +97,127 @@ export default function ActionPanel({ game, busy, advisors, onAction, onChat, on
|
||||
else onAdvisor(prompt.trim() || undefined);
|
||||
};
|
||||
|
||||
// In window mode the surrounding FloatingWindow shows "Your move" and the
|
||||
// "Jump forward" button in its own title bar, so omit both here.
|
||||
const header = inWindow ? null : (
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="size-4 text-primary" />
|
||||
<CardTitle className="text-base">Your move</CardTitle>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
||||
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
||||
<TabsTrigger value="action" data-testid="tab-action">
|
||||
Action
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
||||
Diplomacy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
||||
Advisor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="action" className="space-y-2">
|
||||
<Textarea
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
||||
rows={3}
|
||||
data-testid="action-textarea"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diplo" className="space-y-2">
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-full" data-testid="chat-target">
|
||||
<SelectValue placeholder="Choose a nation to address…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{others.map(([id, n]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Your diplomatic message…"
|
||||
rows={3}
|
||||
data-testid="chat-message"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advisor" className="space-y-2">
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||
data-testid="advisor-prompt"
|
||||
/>
|
||||
{advisors.length > 0 && (
|
||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||
{advisors.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
||||
data-testid="advisor-item"
|
||||
>
|
||||
<span className="flex-1 leading-snug">{a}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
||||
Use
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
{sendLabel}
|
||||
</Button>
|
||||
{busy && (
|
||||
<p
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
|
||||
data-testid="sim-hint"
|
||||
>
|
||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||
The world is thinking — turns can take a few seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (inWindow) {
|
||||
return (
|
||||
<div data-testid="action-panel" className="p-4">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="gap-4" data-testid="action-panel">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="size-4 text-primary" />
|
||||
<CardTitle className="text-base">Your move</CardTitle>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={onJump} disabled={busy} data-testid="jump-forward">
|
||||
{busy ? 'Simulating…' : 'Jump forward'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={setTab} className="gap-3">
|
||||
<TabsList className="grid w-full grid-cols-3" data-testid="action-tabs">
|
||||
<TabsTrigger value="action" data-testid="tab-action">
|
||||
Action
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diplo" data-testid="tab-diplo">
|
||||
Diplomacy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advisor" data-testid="tab-advisor">
|
||||
Advisor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="action" className="space-y-2">
|
||||
<Textarea
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder="Describe your move — e.g. “I mobilize reserves and fortify the western border.”"
|
||||
rows={3}
|
||||
data-testid="action-textarea"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diplo" className="space-y-2">
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-full" data-testid="chat-target">
|
||||
<SelectValue placeholder="Choose a nation to address…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{others.map(([id, n]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{n.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Your diplomatic message…"
|
||||
rows={3}
|
||||
data-testid="chat-message"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advisor" className="space-y-2">
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Optional focus — e.g. “the nuclear standoff”"
|
||||
data-testid="advisor-prompt"
|
||||
/>
|
||||
{advisors.length > 0 && (
|
||||
<ul className="space-y-1.5" data-testid="advisor-list">
|
||||
{advisors.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded-md border bg-card/50 p-2 text-sm"
|
||||
data-testid="advisor-item"
|
||||
>
|
||||
<span className="flex-1 leading-snug">{a}</span>
|
||||
<Button size="sm" variant="outline" onClick={() => applyIdea(a)}>
|
||||
Use
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<Button className="h-11 w-full" onClick={send} disabled={sendDisabled} data-testid="action-submit">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
{sendLabel}
|
||||
</Button>
|
||||
{busy && (
|
||||
<p
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground"
|
||||
data-testid="sim-hint"
|
||||
>
|
||||
<span className="sim-pulse inline-block size-1.5 rounded-full bg-primary" />
|
||||
The world is thinking — turns can take a few seconds.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
{header}
|
||||
<CardContent>{body}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user