History: AI-driven alternate-history simulator
Pax Historia-style sim where the LLM is the world engine.
- React 19 + Vite 6 + Tailwind v4 + shadcn/ui frontend
- Express single-port backend (/api + /sse + built client + SPA fallback)
- SQLite (better-sqlite3) persistence, one row per game
- MapLibre choropleth map (OpenFreeMap dark + ISO_A3 regions)
- Defensive turn loop: strip fences, balanced-{} extract, parse, clamp 0-100,
retry-once nudge, generic 'unclear' fallback; never crashes on bad output
- 5 seed presets (wwii-1939, coldwar-1955, modern-2025, no-wwii-1940, aegis-2400)
- LLM client over native fetch w/ injectable transport (mesh-bot pattern);
LLM_STUB=1 deterministic transport for tests/e2e
- Unit (vitest, 19) + deterministic Playwright e2e
- Multi-stage node:22-slim Docker + Traefik (history.dev.kkhost.pl:4321)
Co-Authored-By: OpenCode <opencode@users.noreply.github.com>
This commit is contained in:
+293
@@ -0,0 +1,293 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Badge } from './components/ui/badge';
|
||||
import { Separator } from './components/ui/separator';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './components/ui/select';
|
||||
import PresetPicker from './components/PresetPicker';
|
||||
import NationSelect from './components/NationSelect';
|
||||
import WorldMap from './components/WorldMap';
|
||||
import NationPanel from './components/NationPanel';
|
||||
import EventFeed from './components/EventFeed';
|
||||
import ActionPanel from './components/ActionPanel';
|
||||
import { api } from './lib/api';
|
||||
import { useSse } from './lib/sse';
|
||||
import { nationColor, type RegionMap } from '@shared/protocol';
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
|
||||
/** Which top-level screen the app is showing. */
|
||||
type Screen = { name: 'home' } | { name: 'pick'; preset: Preset } | { name: 'game' };
|
||||
|
||||
export default function App() {
|
||||
const [screen, setScreen] = useState<Screen>({ name: 'home' });
|
||||
const [presets, setPresets] = useState<PresetMeta[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [game, setGame] = useState<GameState | null>(null);
|
||||
const [regions, setRegions] = useState<RegionMap>({});
|
||||
const [advisors, setAdvisors] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Load the scenario manifest once, on the home screen.
|
||||
useEffect(() => {
|
||||
if (screen.name !== 'home') return;
|
||||
let alive = true;
|
||||
api
|
||||
.listPresets()
|
||||
.then((r) => alive && setPresets(r.presets))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [screen.name]);
|
||||
|
||||
// Keep the choropleth in sync with whatever state is shown (territory changes).
|
||||
useEffect(() => {
|
||||
if (!game) return;
|
||||
api
|
||||
.map(game.id)
|
||||
.then((r) => setRegions(r.regions))
|
||||
.catch(() => {});
|
||||
}, [game]);
|
||||
|
||||
// Resync from the persisted server state on connect and after every turn.
|
||||
// Handlers only call the stable setGame, so no stale-closure concerns.
|
||||
useSse(game?.id ?? null, {
|
||||
onState: (s) => setGame(s),
|
||||
onTurn: (s) => setGame(s),
|
||||
});
|
||||
|
||||
const play = useCallback(async (presetId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { preset } = await api.getPreset(presetId);
|
||||
setScreen({ name: 'pick', preset });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not load scenario');
|
||||
setScreen({ name: 'home' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickNation = useCallback(
|
||||
async (nationId: string) => {
|
||||
if (screen.name !== 'pick') return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { game } = await api.createGame(screen.preset.id, nationId);
|
||||
setGame(game);
|
||||
setAdvisors([]);
|
||||
setScreen({ name: 'game' });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Could not start the game');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[screen],
|
||||
);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (action: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, action);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Action failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runChat = useCallback(
|
||||
async (to: string, message: string) => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.turn(game.id, undefined, { to, message });
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Diplomacy failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const runJump = useCallback(async () => {
|
||||
if (!game) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const { state } = await api.jump(game.id);
|
||||
setGame(state);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Jump failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [game]);
|
||||
|
||||
const askAdvisor = useCallback(
|
||||
async (prompt?: string) => {
|
||||
if (!game) return;
|
||||
try {
|
||||
const { suggestions } = await api.advisor(game.id, prompt);
|
||||
setAdvisors(suggestions);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Advisor failed');
|
||||
}
|
||||
},
|
||||
[game],
|
||||
);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
setScreen({ name: 'home' });
|
||||
setGame(null);
|
||||
setRegions({});
|
||||
setAdvisors([]);
|
||||
}, []);
|
||||
|
||||
if (screen.name === 'game' && game) {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<GameScreen
|
||||
key={game.id}
|
||||
game={game}
|
||||
regions={regions}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={runAction}
|
||||
onChat={runChat}
|
||||
onAdvisor={askAdvisor}
|
||||
onJump={runJump}
|
||||
onHome={goHome}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.name === 'pick') {
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<NationSelect
|
||||
preset={screen.preset}
|
||||
loading={loading}
|
||||
onPick={pickNation}
|
||||
onBack={goHome}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster richColors position="top-right" />
|
||||
<PresetPicker presets={presets} loading={loading} onPlay={play} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GameScreenProps {
|
||||
game: GameState;
|
||||
regions: RegionMap;
|
||||
busy: boolean;
|
||||
advisors: string[];
|
||||
onAction: (a: string) => void;
|
||||
onChat: (to: string, m: string) => void;
|
||||
onAdvisor: (p?: string) => void;
|
||||
onJump: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/** The main play view: map + nation dossier on the left, command deck + event feed on the right. */
|
||||
function GameScreen({
|
||||
game,
|
||||
regions,
|
||||
busy,
|
||||
advisors,
|
||||
onAction,
|
||||
onChat,
|
||||
onAdvisor,
|
||||
onJump,
|
||||
onHome,
|
||||
}: GameScreenProps) {
|
||||
// Which nation's dossier to show. Defaults to the player; remounts per game.
|
||||
const [viewingId, setViewingId] = useState(game.playerNationId);
|
||||
const player = game.nations[game.playerNationId];
|
||||
const viewed = game.nations[viewingId] ?? player;
|
||||
const nations = Object.values(game.nations).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen max-w-[1500px] flex-col px-4 py-5 lg:px-6">
|
||||
<header className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2" data-testid="game-header">
|
||||
<Button variant="ghost" size="sm" onClick={onHome}>
|
||||
← Scenarios
|
||||
</Button>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-block size-3.5 rounded-full" style={{ background: nationColor(game.playerNationId) }} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{player?.name ?? game.playerNationId}</h1>
|
||||
<Badge variant="secondary">{game.presetTitle}</Badge>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Turn <span className="font-semibold tabular-nums text-foreground" data-testid="turn-count">{game.turn}</span>
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="tabular-nums" data-testid="game-date">
|
||||
{game.date}
|
||||
</span>
|
||||
{busy && <span className="animate-pulse text-primary">· simulating…</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid flex-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<section className="min-w-0 space-y-4">
|
||||
<div className="h-[62vh] min-h-[420px] overflow-hidden rounded-xl border">
|
||||
<WorldMap regions={regions} playerNationId={game.playerNationId} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Inspect nation</span>
|
||||
<Select value={viewingId} onValueChange={setViewingId}>
|
||||
<SelectTrigger className="w-56" 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>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-4">
|
||||
<ActionPanel
|
||||
game={game}
|
||||
busy={busy}
|
||||
advisors={advisors}
|
||||
onAction={onAction}
|
||||
onChat={onChat}
|
||||
onAdvisor={onAdvisor}
|
||||
onJump={onJump}
|
||||
/>
|
||||
<div className="h-[46vh] min-h-[280px]" data-testid="event-feed-slot">
|
||||
<EventFeed events={game.events} nations={game.nations} />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import type { GameState } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
game: GameState;
|
||||
/** A turn is currently resolving (action/chat/jump) — disable those controls. */
|
||||
busy: boolean;
|
||||
/** Advisor brainstorm suggestions from the last request. */
|
||||
advisors: string[];
|
||||
onAction: (action: string) => void;
|
||||
onChat: (to: string, message: string) => void;
|
||||
onAdvisor: (prompt?: string) => void;
|
||||
onJump: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's command deck. Three tabs:
|
||||
* - Action: free-text move (the primary way to shape the world).
|
||||
* - Diplomacy: a message to a specific other nation.
|
||||
* - Advisor: ask the LLM for 3–5 in-character action ideas; "Use" feeds one
|
||||
* straight into the Action tab.
|
||||
* A prominent "Jump forward" button auto-simulates the world with no player move.
|
||||
*/
|
||||
export default function ActionPanel({ game, busy, advisors, onAction, onChat, onAdvisor, onJump }: Props) {
|
||||
const [tab, setTab] = useState('action');
|
||||
const [action, setAction] = useState('');
|
||||
const [target, setTarget] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
|
||||
const others = Object.entries(game.nations)
|
||||
.filter(([id]) => id !== game.playerNationId)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const submitAction = () => {
|
||||
const text = action.trim();
|
||||
if (!text || busy) return;
|
||||
setAction('');
|
||||
onAction(text);
|
||||
};
|
||||
|
||||
const submitChat = () => {
|
||||
const msg = message.trim();
|
||||
if (!msg || !target || busy) return;
|
||||
setMessage('');
|
||||
onChat(target, msg);
|
||||
};
|
||||
|
||||
const applyIdea = (idea: string) => {
|
||||
setAction(idea);
|
||||
setTab('action');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="gap-4" data-testid="action-panel">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Command</CardTitle>
|
||||
<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"
|
||||
/>
|
||||
<Button onClick={submitAction} disabled={busy || !action.trim()} className="w-full" data-testid="action-submit">
|
||||
Take action
|
||||
</Button>
|
||||
</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"
|
||||
/>
|
||||
<Button onClick={submitChat} disabled={busy || !message.trim() || !target} className="w-full" data-testid="chat-submit">
|
||||
Send message
|
||||
</Button>
|
||||
</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"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onAdvisor(prompt.trim() || undefined)}
|
||||
className="w-full"
|
||||
data-testid="advisor-ask"
|
||||
>
|
||||
Ask the advisor
|
||||
</Button>
|
||||
{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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { EVENT_COLORS, OUTCOME_COLORS } from '@/lib/colors';
|
||||
import type { EventEntry, Nation } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
events: EventEntry[];
|
||||
nations: Record<string, Nation>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chronological event log (newest first). Each entry shows the turn it resolved,
|
||||
* its date, a color-coded type, a headline, the narrative, the nation it concerns,
|
||||
* and the outcome. Scrolls internally; the parent supplies a bounded height.
|
||||
*/
|
||||
export default function EventFeed({ events, nations }: Props) {
|
||||
const latestFirst = [...events].reverse();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border bg-card" data-testid="event-feed-panel">
|
||||
<div className="flex items-center justify-between px-4 pt-4">
|
||||
<h2 className="text-sm font-semibold">Event feed</h2>
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
{events.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="mx-4 mb-4 min-h-0 flex-1">
|
||||
<div className="space-y-2 pr-1" data-testid="event-feed">
|
||||
{latestFirst.length === 0 && (
|
||||
<p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
No events yet. Take an action, send a message, or jump forward to move the world.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{latestFirst.map((e) => {
|
||||
const nation = nations[e.nation];
|
||||
const color = EVENT_COLORS[e.type] ?? '#94a3b8';
|
||||
const outcome = OUTCOME_COLORS[e.outcome] ?? '#94a3b8';
|
||||
return (
|
||||
<article key={e.id} className="rounded-lg border bg-card/50 p-2.5" data-testid="event-item">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium tabular-nums">T{e.turn}</span>
|
||||
<span>·</span>
|
||||
<span className="tabular-nums">{e.date}</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1.5">
|
||||
<span className="inline-block size-2 rounded-full" style={{ background: color }} />
|
||||
{e.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-snug" style={{ color }}>
|
||||
{e.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm leading-snug text-foreground/80">{e.text}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{nation?.name ?? e.nation}</span>
|
||||
<span
|
||||
className="ml-auto rounded px-1.5 py-0.5 font-medium capitalize"
|
||||
style={{ background: `${outcome}22`, color: outcome }}
|
||||
>
|
||||
{e.outcome}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { nationColor } from '@shared/protocol';
|
||||
import { RELATION_COLORS } from '@/lib/colors';
|
||||
import type { Nation } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
nation: Nation;
|
||||
nations: Record<string, Nation>;
|
||||
/** True when this is the nation the player is leading. */
|
||||
isPlayer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single nation's dossier: identity, the four 0–100 stat bars (colored by the
|
||||
* nation's stable choropleth color), territory count, and its diplomatic posture
|
||||
* toward every other nation. Used for the player (highlighted) or any inspected
|
||||
* nation.
|
||||
*/
|
||||
export default function NationPanel({ nation, nations, isPlayer }: Props) {
|
||||
const color = nationColor(nation.id);
|
||||
const relations = Object.entries(nation.relations).sort(([a], [b]) => a.localeCompare(b));
|
||||
const regions = nation.territory;
|
||||
|
||||
return (
|
||||
<Card data-testid="nation-panel" className={isPlayer ? 'gap-4 border-primary/40' : 'gap-4'}>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="inline-block size-3.5 rounded-full" style={{ background: color }} />
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-lg">{nation.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{nation.type} · {regions.length} region{regions.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isPlayer ? 'default' : 'secondary'} className="shrink-0">
|
||||
{isPlayer ? 'Your nation' : 'Other'}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
<StatBar label="Military" value={nation.stats.military} color={color} testId={isPlayer ? 'stat-military' : undefined} />
|
||||
<StatBar label="Economy" value={nation.stats.economy} color={color} testId={isPlayer ? 'stat-economy' : undefined} />
|
||||
<StatBar label="Stability" value={nation.stats.stability} color={color} testId={isPlayer ? 'stat-stability' : undefined} />
|
||||
<StatBar label="Population" value={nation.stats.population} color={color} testId={isPlayer ? 'stat-population' : undefined} />
|
||||
</div>
|
||||
|
||||
{regions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Territory</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{regions.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="rounded border px-1.5 py-0.5 text-xs tabular-nums"
|
||||
style={{ borderColor: `${color}66`, color }}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Diplomacy</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{relations.map(([id, status]) => {
|
||||
const other = nations[id];
|
||||
const c = RELATION_COLORS[status] ?? '#94a3b8';
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="rounded-md border px-2 py-0.5 text-xs"
|
||||
style={{ borderColor: `${c}66`, color: c }}
|
||||
>
|
||||
{other?.name ?? id}: {status}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import StatBar from './StatBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Preset } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
preset: Preset;
|
||||
loading?: boolean;
|
||||
onPick: (nationId: string) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** Pick which nation to lead, with a short world-context blurb above. */
|
||||
export default function NationSelect({ preset, loading, onPick, onBack }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
|
||||
← Back to scenarios
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{preset.title}</h1>
|
||||
<p
|
||||
className="mt-3 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-muted-foreground"
|
||||
data-testid="world-background"
|
||||
>
|
||||
{preset.worldBackground}
|
||||
</p>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-lg font-semibold">Choose your nation</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{preset.nations.map((n) => (
|
||||
<Card key={n.id} className="flex flex-col" data-testid={`nation-card-${n.id}`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{n.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{n.type} · {n.territory.length} region{n.territory.length === 1 ? '' : 's'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto space-y-1.5">
|
||||
<StatBar label="Military" value={n.stats.military} />
|
||||
<StatBar label="Economy" value={n.stats.economy} />
|
||||
<StatBar label="Stability" value={n.stats.stability} />
|
||||
<Button
|
||||
className={cn('w-full mt-3')}
|
||||
onClick={() => onPick(n.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
Play as {n.name}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import type { PresetMeta } from '@shared/types';
|
||||
|
||||
interface Props {
|
||||
presets: PresetMeta[];
|
||||
loading?: boolean;
|
||||
onPlay: (presetId: string) => void;
|
||||
}
|
||||
|
||||
/** Home screen: a grid of scenario cards, each with a Play button. */
|
||||
export default function PresetPicker({ presets, loading, onPlay }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">History</h1>
|
||||
<p className="mt-2 max-w-2xl text-muted-foreground">
|
||||
An AI-driven alternate-history simulator. Pick a scenario and a nation, then shape the
|
||||
world turn by turn — the LLM is the world engine.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{presets.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
className="flex flex-col transition hover:ring-1 hover:ring-primary/40"
|
||||
data-testid="preset-card"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-lg">{p.title}</CardTitle>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{p.startDate}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-relaxed">{p.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto">
|
||||
<Button className="w-full" onClick={() => onPlay(p.id)} disabled={loading}>
|
||||
Play
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A compact 0-100 stat bar with a value readout. */
|
||||
export default function StatBar({ label, value, color, testId, className }: Props) {
|
||||
const v = Math.max(0, Math.min(100, Math.round(value)));
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 text-xs', className)}>
|
||||
<span className="w-12 shrink-0 capitalize text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${v}%`, background: color ?? 'var(--primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-7 text-right tabular-nums" data-testid={testId}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { RegionMap } from '@shared/protocol';
|
||||
|
||||
// Minimal GeoJSON types (avoids a hard @types/geojson dependency).
|
||||
interface GeoFeature {
|
||||
type: 'Feature';
|
||||
id?: string | number;
|
||||
properties?: Record<string, unknown>;
|
||||
geometry: unknown;
|
||||
}
|
||||
interface FeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: GeoFeature[];
|
||||
}
|
||||
|
||||
// Remote OpenFreeMap dark basemap ("command center" look).
|
||||
const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
|
||||
const COUNTRIES_SOURCE = 'countries';
|
||||
const FILL_LAYER = 'countries-fill';
|
||||
const LINE_LAYER = 'countries-line';
|
||||
|
||||
function applyRegions(
|
||||
geo: FeatureCollection,
|
||||
regions: RegionMap,
|
||||
playerNationId: string,
|
||||
): FeatureCollection {
|
||||
const out: FeatureCollection = JSON.parse(JSON.stringify(geo));
|
||||
for (const f of out.features) {
|
||||
f.properties = f.properties ?? {};
|
||||
const r = f.id ? regions[f.id as string] : undefined;
|
||||
if (r) {
|
||||
f.properties.fill = r.color;
|
||||
f.properties.owner = r.owner;
|
||||
// Highlight the player's regions with a bright stroke.
|
||||
f.properties.stroke = r.ownerId === playerNationId ? '#fbbf24' : 'rgba(255,255,255,0.18)';
|
||||
f.properties.isPlayer = r.ownerId === playerNationId ? 1 : 0;
|
||||
} else {
|
||||
f.properties.fill = 'rgba(148,163,184,0.06)';
|
||||
f.properties.stroke = 'rgba(255,255,255,0.08)';
|
||||
f.properties.owner = '';
|
||||
f.properties.isPlayer = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
regions: RegionMap;
|
||||
playerNationId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* World basemap (MapLibre GL) + a country choropleth colored by the owning
|
||||
* nation. Ported from flight's Map.tsx (remote style + idempotent `ensureLayers`
|
||||
* re-establish on `styledata`), stripped of all flight-specific data/logic.
|
||||
*/
|
||||
export default function WorldMap({ regions, playerNationId, className }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const baseGeoRef = useRef<FeatureCollection | null>(null);
|
||||
// Latest props, so the mount effect's (re)establish closure always sees them.
|
||||
const regionsRef = useRef(regions);
|
||||
const playerRef = useRef(playerNationId);
|
||||
regionsRef.current = regions;
|
||||
playerRef.current = playerNationId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
let cancelled = false;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: STYLE_URL,
|
||||
center: [15, 50],
|
||||
zoom: 2.5,
|
||||
minZoom: 1.25,
|
||||
maxZoom: 8,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = map;
|
||||
|
||||
const loadGeo = (): Promise<FeatureCollection> =>
|
||||
baseGeoRef.current
|
||||
? Promise.resolve(baseGeoRef.current)
|
||||
: fetch('/countries.geo.json')
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`countries.geo.json → HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((g: FeatureCollection) => {
|
||||
baseGeoRef.current = g;
|
||||
return g;
|
||||
});
|
||||
|
||||
const onCountryClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const f = e.features?.[0];
|
||||
if (!f) return;
|
||||
const p = f.properties as { owner?: string; name?: string; isPlayer?: number };
|
||||
const label = p.owner ? `${p.owner}${p.isPlayer ? ' (your nation)' : ''}` : p.name ?? 'Unclaimed';
|
||||
new maplibregl.Popup({ offset: 8 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setText(label)
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
// Idempotent: re-adds the countries source + layers after any setStyle, and on
|
||||
// first load. (Vite serves MapLibre's worker automatically, so no setWorkerUrl.)
|
||||
const ensureLayers = async () => {
|
||||
let geo: FeatureCollection;
|
||||
try {
|
||||
geo = await loadGeo();
|
||||
} catch (err) {
|
||||
console.error('failed to load countries geojson', err);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (!map.getSource(COUNTRIES_SOURCE)) {
|
||||
map.addSource(COUNTRIES_SOURCE, {
|
||||
type: 'geojson',
|
||||
data: applyRegions(geo, regionsRef.current, playerRef.current),
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(FILL_LAYER)) {
|
||||
map.addLayer({
|
||||
id: FILL_LAYER,
|
||||
type: 'fill',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'fill-color': ['get', 'fill'],
|
||||
'fill-opacity': 0.55,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!map.getLayer(LINE_LAYER)) {
|
||||
map.addLayer({
|
||||
id: LINE_LAYER,
|
||||
type: 'line',
|
||||
source: COUNTRIES_SOURCE,
|
||||
paint: {
|
||||
'line-color': ['get', 'stroke'],
|
||||
'line-width': 0.75,
|
||||
},
|
||||
});
|
||||
}
|
||||
map.off('click', FILL_LAYER, onCountryClick);
|
||||
map.on('click', FILL_LAYER, onCountryClick);
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) void ensureLayers();
|
||||
else map.on('styledata', () => void ensureLayers());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-color the choropleth whenever the region ownership changes (each turn).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const geo = baseGeoRef.current;
|
||||
if (!map || !geo) return;
|
||||
const src = map.getSource(COUNTRIES_SOURCE) as maplibregl.GeoJSONSource | undefined;
|
||||
if (src) void src.setData(applyRegions(geo, regions, playerNationId));
|
||||
}, [regions, playerNationId]);
|
||||
|
||||
return <div ref={containerRef} data-testid="world-map" className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Dark "command center" theme. `:root` is the dark palette (the app is dark by
|
||||
* default); `.dark` mirrors it so any `.dark`-conditional component styling stays
|
||||
* consistent. shadcn/ui components read the `--color-*` aliases defined in
|
||||
* `@theme inline` below, which map onto these variables.
|
||||
*/
|
||||
:root {
|
||||
--background: #0a0e14;
|
||||
--foreground: #e2e8f0;
|
||||
--card: #101724;
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #0d131e;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #f59e0b;
|
||||
--primary-foreground: #0a0e14;
|
||||
--secondary: #1b2636;
|
||||
--secondary-foreground: #cbd5e1;
|
||||
--muted: #152030;
|
||||
--muted-foreground: #8aa0b8;
|
||||
--accent: #0e2a3a;
|
||||
--accent-foreground: #67e8f9;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #1f2b3d;
|
||||
--input: #22304a;
|
||||
--ring: #f59e0b;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0e14;
|
||||
--foreground: #e2e8f0;
|
||||
--card: #101724;
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #0d131e;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #f59e0b;
|
||||
--primary-foreground: #0a0e14;
|
||||
--secondary: #1b2636;
|
||||
--secondary-foreground: #cbd5e1;
|
||||
--muted: #152030;
|
||||
--muted-foreground: #8aa0b8;
|
||||
--accent: #0e2a3a;
|
||||
--accent-foreground: #67e8f9;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #1f2b3d;
|
||||
--input: #22304a;
|
||||
--ring: #f59e0b;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* MapLibre canvas: give the map pane a solid dark base behind the tiles. */
|
||||
.maplibregl-canvas {
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Client-side API helper. All requests are same-origin relative paths (they are
|
||||
// proxied by Vite in dev and served by Express in prod), so no origin is ever
|
||||
// hardcoded — this works unchanged behind the Traefik proxy.
|
||||
import type { GameState, Preset, PresetMeta } from '@shared/types';
|
||||
import type { AdvisorResponse, CreateGameResponse, TurnResponse } from '@shared/protocol';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listPresets: () => req<{ presets: PresetMeta[] }>('/api/presets'),
|
||||
getPreset: (id: string) => req<{ preset: Preset }>(`/api/presets/${id}`),
|
||||
createGame: (presetId: string, playerNationId: string) =>
|
||||
req<CreateGameResponse>('/api/games', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ presetId, playerNationId }),
|
||||
}),
|
||||
getGame: (id: string) => req<{ game: GameState }>(`/api/games/${id}`),
|
||||
turn: (id: string, action?: string, chat?: { to: string; message: string }) =>
|
||||
req<TurnResponse>(`/api/games/${id}/turn`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, chat }),
|
||||
}),
|
||||
jump: (id: string) =>
|
||||
req<TurnResponse>(`/api/games/${id}/jump`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
advisor: (id: string, prompt?: string) =>
|
||||
req<AdvisorResponse>(`/api/games/${id}/advisor`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt }),
|
||||
}),
|
||||
map: (id: string) =>
|
||||
req<{ regions: Record<string, { color: string; owner: string; ownerId: string }> }>(
|
||||
`/api/games/${id}/map`,
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// Small color maps for the "command center" theme. Pure + shared so the event
|
||||
// feed, nation panel, and any future view all agree on a type/status → color.
|
||||
import type { EventOutcome, EventType, RelationStatus } from '@shared/types';
|
||||
|
||||
export const EVENT_COLORS: Record<EventType, string> = {
|
||||
military: '#ef4444',
|
||||
diplomatic: '#3b82f6',
|
||||
economic: '#22c55e',
|
||||
political: '#f59e0b',
|
||||
social: '#06b6d4',
|
||||
disaster: '#f97316',
|
||||
scientific: '#a855f7',
|
||||
cultural: '#ec4899',
|
||||
};
|
||||
|
||||
export const RELATION_COLORS: Record<RelationStatus, string> = {
|
||||
ally: '#22c55e',
|
||||
neutral: '#94a3b8',
|
||||
rival: '#f59e0b',
|
||||
hostile: '#f97316',
|
||||
war: '#ef4444',
|
||||
};
|
||||
|
||||
export const OUTCOME_COLORS: Record<EventOutcome, string> = {
|
||||
success: '#22c55e',
|
||||
failure: '#ef4444',
|
||||
partial: '#f59e0b',
|
||||
neutral: '#94a3b8',
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { GameState } from '@shared/types';
|
||||
import type { TurnResult } from '@shared/protocol';
|
||||
|
||||
interface SseHandlers {
|
||||
onState?: (state: GameState) => void;
|
||||
onTurn?: (state: GameState, result: TurnResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a game's SSE stream. The server sends a `state` event on connect
|
||||
* and a `turn` event after every turn; EventSource auto-reconnects on drop, so a
|
||||
* reload always resyncs to the persisted server state.
|
||||
*
|
||||
* Handlers are kept in a ref so the (id-keyed) effect always invokes the latest
|
||||
* callbacks without re-subscribing on every render.
|
||||
*/
|
||||
export function useSse(id: string | null, handlers: SseHandlers): void {
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const es = new EventSource(`/sse/${id}`);
|
||||
const onState = (e: MessageEvent) => {
|
||||
try {
|
||||
handlersRef.current.onState?.(JSON.parse(e.data).state as GameState);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
const onTurn = (e: MessageEvent) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data) as { state: GameState; result: TurnResult };
|
||||
handlersRef.current.onTurn?.(d.state, d.result);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
es.addEventListener('state', onState);
|
||||
es.addEventListener('turn', onTurn);
|
||||
return () => es.close();
|
||||
}, [id]);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** shadcn/ui class combiner. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (!el) throw new Error('#root not found');
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user