Add microservices, web UI, and replay tooling
Some checks failed
ci / tests (push) Has been cancelled

This commit is contained in:
dan
2025-12-25 03:28:40 +03:00
commit 46a07f548b
72 changed files with 9142 additions and 0 deletions

15
web/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catan Arena</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

20
web/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "catan-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.10"
}
}

807
web/src/App.jsx Normal file
View File

@@ -0,0 +1,807 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Routes, Route, Link, useNavigate, useParams } from 'react-router-dom';
import { apiFetch, clearToken, getToken, getUsername, setToken, wsUrl } from './api.js';
const resourceColors = {
brick: '#c46a44',
lumber: '#4a6d4a',
wool: '#8cc071',
grain: '#e2c065',
ore: '#7a7c83',
desert: '#d8c18f',
};
function downloadJson(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function Header({ user }) {
const navigate = useNavigate();
return (
<div className="header">
<div className="logo">CATAN ARENA</div>
<div className="nav">
{user && (
<>
<Link to="/">Lobby</Link>
<Link to="/analytics">Analytics</Link>
<Link to="/replays">Replays</Link>
</>
)}
<span className="small">{user || 'Guest'}</span>
{user && (
<button
className="button ghost"
onClick={() => {
clearToken();
navigate('/login');
}}
>
Logout
</button>
)}
</div>
</div>
);
}
function LoginPage({ onAuth }) {
const [login, setLogin] = useState({ username: '', password: '' });
const [register, setRegister] = useState({ username: '', password: '' });
const navigate = useNavigate();
async function submitLogin() {
const data = await apiFetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(login),
});
setToken(data.token, data.username);
onAuth(data.username);
navigate('/');
}
async function submitRegister() {
const data = await apiFetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(register),
});
setToken(data.token, data.username);
onAuth(data.username);
navigate('/');
}
return (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Login</div>
<div className="panel-stack">
<input
className="input"
placeholder="Username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
/>
<input
className="input"
type="password"
placeholder="Password"
value={login.password}
onChange={(e) => setLogin({ ...login, password: e.target.value })}
/>
<button className="button" onClick={submitLogin}>Enter Arena</button>
</div>
</div>
<div className="card">
<div className="section-title">Create Account</div>
<div className="panel-stack">
<input
className="input"
placeholder="Username"
value={register.username}
onChange={(e) => setRegister({ ...register, username: e.target.value })}
/>
<input
className="input"
type="password"
placeholder="Password"
value={register.password}
onChange={(e) => setRegister({ ...register, password: e.target.value })}
/>
<button className="button secondary" onClick={submitRegister}>Register</button>
</div>
</div>
</div>
</div>
);
}
function LobbyPage() {
const [games, setGames] = useState([]);
const [models, setModels] = useState([]);
const [stats, setStats] = useState(null);
const [create, setCreate] = useState({ name: '', max_players: 4 });
const navigate = useNavigate();
async function load() {
const [lobby, modelList, statsData] = await Promise.all([
apiFetch('/api/lobby'),
apiFetch('/api/models'),
apiFetch('/api/stats'),
]);
setGames(lobby.games || []);
setModels(modelList.models || []);
setStats(statsData);
}
useEffect(() => {
load().catch(console.error);
}, []);
async function createGame() {
await apiFetch('/api/games', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(create),
});
await load();
}
async function joinGame(id) {
await apiFetch(`/api/games/${id}/join`, { method: 'POST' });
navigate(`/game/${id}`);
}
async function watchGame(id) {
navigate(`/game/${id}`);
}
async function addAi(id) {
const type = window.prompt('AI type: random or model?', 'random');
if (!type) return;
const payload = { ai_type: type };
if (type === 'model') {
payload.model_name = window.prompt(`Model name: ${models.join(', ')}`);
}
await apiFetch(`/api/games/${id}/add-ai`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
await load();
}
async function startGame(id) {
await apiFetch(`/api/games/${id}/start`, { method: 'POST' });
navigate(`/game/${id}`);
}
return (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Lobby</div>
<div className="panel-stack">
{games.length === 0 && <div className="small">No active games.</div>}
{games.map((game) => (
<div className="card" key={game.id}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{game.name}</strong>
<span className="badge">{game.status}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{game.players.map((slot) => (
<span className="player-tag" key={slot.slot_id}>
{slot.name || 'Open'}
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button className="button" onClick={() => joinGame(game.id)}>Join</button>
<button className="button ghost" onClick={() => watchGame(game.id)}>Watch</button>
<button className="button secondary" onClick={() => addAi(game.id)}>Add AI</button>
<button className="button" onClick={() => startGame(game.id)}>Start</button>
</div>
</div>
))}
</div>
</div>
<div className="panel-stack">
<div className="card">
<div className="section-title">Create Game</div>
<div className="panel-stack">
<input
className="input"
placeholder="Game name"
value={create.name}
onChange={(e) => setCreate({ ...create, name: e.target.value })}
/>
<select
className="input"
value={create.max_players}
onChange={(e) => setCreate({ ...create, max_players: parseInt(e.target.value, 10) })}
>
<option value={2}>2 players</option>
<option value={3}>3 players</option>
<option value={4}>4 players</option>
</select>
<button className="button" onClick={createGame}>Create</button>
</div>
</div>
<div className="card">
<div className="section-title">Your Stats</div>
{stats && (
<div className="panel-stack">
<div>Total games: {stats.total_games}</div>
<div>Finished games: {stats.finished_games}</div>
<div>Your games: {stats.user_games}</div>
<div>Your wins: {stats.user_wins}</div>
<div>Avg turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
</div>
)}
</div>
<div className="card">
<div className="section-title">AI Models</div>
<div className="panel-stack">
{models.map((model) => (
<span className="player-tag" key={model}>{model}</span>
))}
{models.length === 0 && <div className="small">No models found.</div>}
</div>
</div>
</div>
</div>
</div>
);
}
function cubeToPixel(coord, scale) {
const [x, , z] = coord;
const sqrt3 = Math.sqrt(3);
return {
x: scale * (sqrt3 * x + (sqrt3 / 2) * z),
y: scale * (1.5 * z),
};
}
function hexCorners(cx, cy, size) {
const points = [];
for (let i = 0; i < 6; i += 1) {
const angle = (Math.PI / 180) * (60 * i - 30);
points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]);
}
return points;
}
function Board({ board, game }) {
if (!board || !game) return null;
const size = 48;
const cornerScale = size / 3;
const hexes = Object.values(board.hexes || {});
const corners = board.corners || {};
const edges = Object.values(board.edges || {});
const hexPositions = hexes.map((hex) => ({ ...hex, pos: cubeToPixel(hex.coord, size) }));
const cornerPositions = {};
Object.entries(corners).forEach(([id, corner]) => {
cornerPositions[id] = { ...corner, pos: cubeToPixel(corner.coord, cornerScale) };
});
const allPositions = [
...hexPositions.map((h) => h.pos),
...Object.values(cornerPositions).map((c) => c.pos),
];
const minX = Math.min(...allPositions.map((p) => p.x));
const maxX = Math.max(...allPositions.map((p) => p.x));
const minY = Math.min(...allPositions.map((p) => p.y));
const maxY = Math.max(...allPositions.map((p) => p.y));
const padding = 80;
const viewBox = `${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`;
const playerColors = {};
Object.entries(game.players || {}).forEach(([name, pdata]) => {
playerColors[name] = pdata.color || '#333';
});
const roadLines = edges
.filter((edge) => edge.owner)
.map((edge) => {
const a = cornerPositions[edge.a];
const b = cornerPositions[edge.b];
if (!a || !b) return '';
const color = playerColors[edge.owner] || '#222';
return `<line x1="${a.pos.x}" y1="${a.pos.y}" x2="${b.pos.x}" y2="${b.pos.y}" stroke="${color}" stroke-width="6" stroke-linecap="round" />`;
})
.join('');
const cornerNodes = Object.entries(cornerPositions)
.filter(([, corner]) => corner.owner)
.map(([, corner]) => {
const radius = corner.building === 'city' ? 10 : 7;
const color = playerColors[corner.owner] || '#222';
return `<circle cx="${corner.pos.x}" cy="${corner.pos.y}" r="${radius}" fill="${color}" stroke="#fff" stroke-width="2" />`;
})
.join('');
const hexShapes = hexPositions
.map((hex) => {
const points = hexCorners(hex.pos.x, hex.pos.y, size - 4)
.map((pt) => pt.join(','))
.join(' ');
const fill = resourceColors[hex.resource] || '#e0c89a';
const number = hex.number || '';
const robber = hex.robber ? "<circle r='8' fill='rgba(0,0,0,0.4)' />" : '';
return `
<g>
<polygon points="${points}" fill="${fill}" stroke="rgba(0,0,0,0.2)" stroke-width="2" />
<text x="${hex.pos.x}" y="${hex.pos.y}" text-anchor="middle" font-size="16" fill="#3a2a1a" font-weight="600">${number}</text>
<g transform="translate(${hex.pos.x}, ${hex.pos.y + 20})">${robber}</g>
</g>
`;
})
.join('');
return (
<svg className="board-svg" viewBox={viewBox} dangerouslySetInnerHTML={{ __html: `${hexShapes}${roadLines}${cornerNodes}` }} />
);
}
function ActionForm({ action, onSubmit }) {
const [payload, setPayload] = useState(action.payload || {});
useEffect(() => {
setPayload(action.payload || {});
}, [action]);
if (action.type === 'move_robber' || action.type === 'play_knight') {
const options = action.payload.options || [];
const selected = payload.hex ?? (options[0]?.hex ?? 0);
const victims = (options.find((opt) => opt.hex === selected) || {}).victims || [];
return (
<div className="panel-stack">
<select
className="input"
value={selected}
onChange={(e) => setPayload({ ...payload, hex: parseInt(e.target.value, 10) })}
>
{options.map((opt) => (
<option key={opt.hex} value={opt.hex}>Hex {opt.hex}</option>
))}
</select>
<select
className="input"
value={payload.victim || ''}
onChange={(e) => setPayload({ ...payload, victim: e.target.value || undefined })}
>
<option value="">No victim</option>
{victims.map((victim) => (
<option key={victim} value={victim}>{victim}</option>
))}
</select>
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
</div>
);
}
if (action.type === 'play_road_building') {
const edges = action.payload.edges || [];
return (
<div className="panel-stack">
<div className="small">Select up to two edges</div>
<select
className="input"
multiple
onChange={(e) => {
const values = Array.from(e.target.selectedOptions).map((opt) => parseInt(opt.value, 10));
setPayload({ edges: values.slice(0, 2) });
}}
>
{edges.map((edge) => (
<option key={edge} value={edge}>Edge {edge}</option>
))}
</select>
<button className="button" onClick={() => onSubmit(payload)}>Execute</button>
</div>
);
}
if (action.type === 'play_year_of_plenty') {
const bank = action.payload.bank || {};
const resources = Object.keys(bank);
return (
<div className="panel-stack">
<select className="input" onChange={(e) => setPayload({ ...payload, res1: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<select className="input" onChange={(e) => setPayload({ ...payload, res2: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<button
className="button"
onClick={() => onSubmit({ resources: [payload.res1 || resources[0], payload.res2 || resources[0]] })}
>
Execute
</button>
</div>
);
}
if (action.type === 'play_monopoly') {
const resources = action.payload.resources || ['brick', 'lumber', 'wool', 'grain', 'ore'];
return (
<div className="panel-stack">
<select className="input" onChange={(e) => setPayload({ resource: e.target.value })}>
{resources.map((res) => (
<option key={res} value={res}>{res}</option>
))}
</select>
<button className="button" onClick={() => onSubmit({ resource: payload.resource || resources[0] })}>Execute</button>
</div>
);
}
if (action.type === 'discard') {
const resources = action.payload.resources || {};
return (
<div className="panel-stack">
{Object.entries(resources).map(([res, count]) => (
<div key={res} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="small">{res} ({count})</span>
<input
className="input"
type="number"
min="0"
max={count}
value={payload.cards?.[res] || 0}
onChange={(e) => {
setPayload({
...payload,
cards: { ...payload.cards, [res]: parseInt(e.target.value, 10) || 0 },
});
}}
/>
</div>
))}
<button className="button" onClick={() => onSubmit({ player: getUsername(), cards: payload.cards || {} })}>Discard</button>
</div>
);
}
return <button className="button" onClick={() => onSubmit(action.payload)}>Execute</button>;
}
function GamePage() {
const { gameId } = useParams();
const [state, setState] = useState(null);
const [legalActions, setLegalActions] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(0);
const [trade, setTrade] = useState({ to_player: '', offer: '', request: '' });
useEffect(() => {
let socket;
async function load() {
const data = await apiFetch(`/api/games/${gameId}`);
setState(data);
setLegalActions(data.legal_actions || []);
socket = new WebSocket(wsUrl(`/ws/games/${gameId}`));
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'state') {
setState(msg.data);
setLegalActions(msg.data.legal_actions || []);
setSelectedIdx(0);
}
};
}
load().catch(console.error);
return () => socket && socket.close();
}, [gameId]);
const currentAction = legalActions[selectedIdx];
async function submitAction(payload) {
await apiFetch(`/api/games/${gameId}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: currentAction.type, payload }),
});
}
async function submitTrade() {
const offer = trade.offer.split(',').reduce((acc, item) => {
const [res, amt] = item.split(':').map((v) => v.trim());
if (res && amt) acc[res] = parseInt(amt, 10) || 0;
return acc;
}, {});
const request = trade.request.split(',').reduce((acc, item) => {
const [res, amt] = item.split(':').map((v) => v.trim());
if (res && amt) acc[res] = parseInt(amt, 10) || 0;
return acc;
}, {});
await apiFetch(`/api/games/${gameId}/trade/offer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from_player: getUsername(),
to_player: trade.to_player || null,
offer,
request,
}),
});
}
async function respondTrade(tradeId, accept) {
await apiFetch(`/api/games/${gameId}/trade/${tradeId}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player: getUsername(), accept }),
});
}
if (!state) {
return <div className="container">Loading...</div>;
}
return (
<div className="container">
<div className="grid three">
<div className="panel-stack">
<div className="card">
<div className="section-title">Players</div>
<div className="panel-stack">
{state.game && Object.entries(state.game.players).map(([name, pdata]) => (
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="player-tag" style={{ background: pdata.color || '#333', color: 'white' }}>{name}</span>
<span className="small">VP {pdata.victory_points}</span>
<span className="small">{pdata.resources?.hidden !== undefined ? `Hidden ${pdata.resources.hidden}` : JSON.stringify(pdata.resources)}</span>
</div>
))}
</div>
</div>
<div className="card">
<div className="section-title">Actions</div>
{legalActions.length === 0 && <div className="small">Waiting for your turn</div>}
{legalActions.length > 0 && (
<div className="panel-stack">
<select
className="input"
value={selectedIdx}
onChange={(e) => setSelectedIdx(parseInt(e.target.value, 10))}
>
{legalActions.map((action, idx) => (
<option key={`${action.type}-${idx}`} value={idx}>{action.type}</option>
))}
</select>
<ActionForm action={currentAction} onSubmit={submitAction} />
</div>
)}
</div>
<div className="card">
<div className="section-title">Trades</div>
<div className="panel-stack">
{state.pending_trades?.length === 0 && <div className="small">No offers.</div>}
{state.pending_trades?.map((offer) => (
<div key={offer.id} className="panel-stack">
<div className="small">{offer.from_player} {offer.to_player || 'any'} | {JSON.stringify(offer.offer)} for {JSON.stringify(offer.request)}</div>
{offer.to_player === getUsername() || offer.to_player === null ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="button" onClick={() => respondTrade(offer.id, true)}>Accept</button>
<button className="button ghost" onClick={() => respondTrade(offer.id, false)}>Decline</button>
</div>
) : null}
</div>
))}
<div className="panel-stack">
<input className="input" placeholder="Target player (optional)" value={trade.to_player} onChange={(e) => setTrade({ ...trade, to_player: e.target.value })} />
<input className="input" placeholder="Offer (e.g. brick:1,lumber:1)" value={trade.offer} onChange={(e) => setTrade({ ...trade, offer: e.target.value })} />
<input className="input" placeholder="Request (e.g. grain:1)" value={trade.request} onChange={(e) => setTrade({ ...trade, request: e.target.value })} />
<button className="button secondary" onClick={submitTrade}>Propose Trade</button>
</div>
</div>
</div>
</div>
<div className="board-shell">
<Board board={state.board} game={state.game} />
</div>
<div className="panel-stack">
<div className="card">
<div className="section-title">Status</div>
<div className="panel-stack">
<div>Phase: {state.game?.phase || '-'}</div>
<div>Current: {state.game?.current_player || '-'}</div>
<div>Last roll: {state.game?.last_roll || '-'}</div>
<div>Winner: {state.game?.winner || '-'}</div>
</div>
</div>
<div className="card">
<div className="section-title">AI Debug</div>
<div className="small">
{state.history?.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0)
? JSON.stringify(state.history.slice().reverse().find((entry) => entry.meta && Object.keys(entry.meta).length > 0).meta)
: 'No debug data.'}
</div>
</div>
<div className="card">
<div className="section-title">History</div>
<div className="history">
{(state.history || []).slice().reverse().map((entry) => (
<div key={entry.idx} className="small">{entry.actor}: {entry.action.type}</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
function AnalyticsPage() {
const [stats, setStats] = useState(null);
useEffect(() => {
apiFetch('/api/stats').then(setStats).catch(console.error);
}, []);
return (
<div className="container">
<div className="card">
<div className="section-title">Global Stats</div>
{stats && (
<div className="panel-stack">
<div>Total games: {stats.total_games}</div>
<div>Finished: {stats.finished_games}</div>
<div>Average turns: {Number(stats.avg_turns || 0).toFixed(1)}</div>
</div>
)}
</div>
</div>
);
}
function ReplaysPage() {
const [replays, setReplays] = useState([]);
const navigate = useNavigate();
useEffect(() => {
apiFetch('/api/replays').then((data) => setReplays(data.replays || [])).catch(console.error);
}, []);
async function exportReplay(id) {
const data = await apiFetch(`/api/replays/${id}/export`);
downloadJson(data, `catan-replay-${id}.json`);
}
async function importReplay(event) {
const file = event.target.files?.[0];
if (!file) return;
const text = await file.text();
const payload = JSON.parse(text);
await apiFetch('/api/replays/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
event.target.value = '';
const data = await apiFetch('/api/replays');
setReplays(data.replays || []);
}
return (
<div className="container">
<div className="card">
<div className="section-title">Replays</div>
<div className="panel-stack">
<label className="button secondary" htmlFor="replay-import">
Import Replay
</label>
<input
id="replay-import"
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={importReplay}
/>
</div>
<div className="panel-stack">
{replays.map((replay) => (
<div key={replay.id} className="card">
<div>{replay.players.join(', ')} | Winner: {replay.winner || '-'}</div>
<div className="small">Actions: {replay.total_actions}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="button" onClick={() => navigate(`/replays/${replay.id}`)}>View</button>
<button className="button ghost" onClick={() => exportReplay(replay.id)}>Export</button>
</div>
</div>
))}
{replays.length === 0 && <div className="small">No replays yet.</div>}
</div>
</div>
</div>
);
}
function ReplayViewer() {
const { replayId } = useParams();
const [detail, setDetail] = useState(null);
const [step, setStep] = useState(0);
const [state, setState] = useState(null);
useEffect(() => {
apiFetch(`/api/replays/${replayId}`).then(setDetail).catch(console.error);
}, [replayId]);
useEffect(() => {
apiFetch(`/api/replays/${replayId}/state?step=${step}`).then(setState).catch(console.error);
}, [replayId, step]);
if (!detail) {
return <div className="container">Loading replay</div>;
}
async function exportReplay() {
const data = await apiFetch(`/api/replays/${replayId}/export`);
downloadJson(data, `catan-replay-${replayId}.json`);
}
return (
<div className="container">
<div className="grid two">
<div className="card">
<div className="section-title">Replay {detail.id}</div>
<div className="panel-stack">
<div>Players: {detail.players.join(', ')}</div>
<div>Winner: {detail.winner || '-'}</div>
<div>Actions: {detail.total_actions}</div>
<button className="button secondary" onClick={exportReplay}>Export JSON</button>
<input
className="input"
type="range"
min="0"
max={detail.total_actions}
value={step}
onChange={(e) => setStep(parseInt(e.target.value, 10))}
/>
<div className="small">Step {step}</div>
</div>
</div>
<div className="board-shell">
<Board board={state?.board} game={state?.game} />
</div>
</div>
</div>
);
}
export default function App() {
const [user, setUser] = useState(getUsername());
const hasToken = getToken();
useEffect(() => {
if (!hasToken) {
setUser(null);
}
}, [hasToken]);
return (
<div className="app">
<Header user={user} />
<Routes>
<Route path="/login" element={<LoginPage onAuth={setUser} />} />
<Route path="/" element={user ? <LobbyPage /> : <LoginPage onAuth={setUser} />} />
<Route path="/game/:gameId" element={<GamePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/replays" element={<ReplaysPage />} />
<Route path="/replays/:replayId" element={<ReplayViewer />} />
</Routes>
</div>
);
}

37
web/src/api.js Normal file
View File

@@ -0,0 +1,37 @@
export function getToken() {
return localStorage.getItem('catan_token');
}
export function setToken(token, username) {
localStorage.setItem('catan_token', token);
localStorage.setItem('catan_user', username);
}
export function clearToken() {
localStorage.removeItem('catan_token');
localStorage.removeItem('catan_user');
}
export function getUsername() {
return localStorage.getItem('catan_user');
}
export async function apiFetch(path, options = {}) {
const headers = options.headers || {};
const token = getToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(path, { ...options, headers });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
export function wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const token = getToken();
return `${proto}://${window.location.host}${path}?token=${token}`;
}

13
web/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

180
web/src/styles.css Normal file
View File

@@ -0,0 +1,180 @@
:root {
color-scheme: light;
--ink: #1e1b16;
--sand: #f4ecd8;
--clay: #d99560;
--ocean: #3a6d7a;
--forest: #3c5a3c;
--sun: #f2c46c;
--stone: #6c6c6c;
--wheat: #d6b35f;
--panel: #fff8e9;
--accent: #b1552f;
--shadow: rgba(30, 27, 22, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", sans-serif;
background: radial-gradient(circle at top, #fff6e6 0%, #efe0c2 45%, #e5d0a7 100%);
color: var(--ink);
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
#root {
min-height: 100vh;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 28px;
background: linear-gradient(120deg, rgba(255, 245, 215, 0.95), rgba(255, 231, 186, 0.95));
box-shadow: 0 8px 24px var(--shadow);
border-bottom: 1px solid rgba(30, 27, 22, 0.1);
}
.logo {
font-family: "Cinzel", serif;
font-size: 24px;
letter-spacing: 3px;
}
.nav {
display: flex;
gap: 16px;
align-items: center;
}
.container {
flex: 1;
padding: 28px;
}
.grid {
display: grid;
gap: 24px;
}
.grid.two {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.grid.three {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.card {
background: var(--panel);
border-radius: 16px;
padding: 18px 22px;
box-shadow: 0 18px 40px var(--shadow);
border: 1px solid rgba(30, 27, 22, 0.08);
}
.section-title {
font-family: "Cinzel", serif;
font-size: 18px;
margin-bottom: 12px;
}
.input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(30, 27, 22, 0.2);
background: white;
font-size: 14px;
}
.button {
border: none;
border-radius: 10px;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: white;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.button.secondary {
background: var(--ocean);
}
.button.ghost {
background: transparent;
border: 1px solid rgba(30, 27, 22, 0.2);
color: var(--ink);
}
.badge {
background: var(--accent);
color: white;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.player-tag {
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
background: rgba(0, 0, 0, 0.08);
}
.panel-stack {
display: grid;
gap: 12px;
}
.board-shell {
background: radial-gradient(circle at center, #f7efd9 0%, #eddcb8 60%, #e0c89a 100%);
border-radius: 24px;
padding: 12px;
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.6);
}
.board-svg {
width: 100%;
height: 520px;
}
.history {
max-height: 240px;
overflow: auto;
font-size: 13px;
}
.small {
font-size: 12px;
opacity: 0.7;
}
@media (max-width: 900px) {
.container {
padding: 18px;
}
.board-svg {
height: 360px;
}
}

16
web/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
});