Add React frontend and clean up legacy HTTP backend

This commit is contained in:
Tim
2026-06-15 22:20:56 +02:00
parent b8e2d15e27
commit beaf142ee4
40 changed files with 8328 additions and 437 deletions
+73
View File
@@ -0,0 +1,73 @@
import { useState } from 'react';
import { useGameStore } from '../store/gameStore';
import NameModal from '../components/NameModal';
import RulesModal from '../components/RulesModal';
type ModalState = { mode: 'create' } | { mode: 'join'; gid: string } | null;
export default function GameList() {
const games = useGameStore((s) => s.games);
const [modal, setModal] = useState<ModalState>(null);
const [showRules, setShowRules] = useState(false);
return (
<div className="max-w-md mx-auto p-4 pt-8">
<h1 className="text-2xl font-bold text-center mb-6 tracking-wide">Bridzik</h1>
<div className="flex flex-col gap-3 mb-6">
{games.length === 0 && (
<p className="text-center text-gray-500 py-4">Ziadne hry. Vytvor prvu!</p>
)}
{games.map((g) => {
const full = g.players.length >= 4;
const unavailable = full || g.started;
return (
<div
key={g.gid}
className="flex items-center justify-between bg-slate-800 rounded-xl px-4 py-3"
>
<div>
<p className="font-semibold">{g.name}</p>
<p className="text-xs text-gray-400">
{g.players.length}/4 hracov
{g.started ? ' · zacata' : ''}
</p>
</div>
<button
disabled={unavailable}
onClick={() => setModal({ mode: 'join', gid: g.gid })}
className="px-4 py-1.5 rounded-lg text-sm font-semibold bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-default"
>
{full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'}
</button>
</div>
);
})}
</div>
<button
onClick={() => setModal({ mode: 'create' })}
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg"
>
+ Vytvorit novu hru
</button>
<button
onClick={() => setShowRules(true)}
className="w-full py-2 rounded-xl bg-slate-700 hover:bg-slate-600 text-sm text-gray-300"
>
Pravidlá hry
</button>
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
{modal && (
<NameModal
mode={modal.mode}
gid={modal.mode === 'join' ? modal.gid : undefined}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useNavigate } from 'react-router-dom';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
import { leaveGame } from '../lib/leaveGame';
interface Props {
players: PlayerInfo[];
standings: number[][][];
}
export default function GameOver({ players, standings }: Props) {
const navigate = useNavigate();
const totals = players
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
.sort((a, b) => b.total - a.total);
const handleLeave = () => leaveGame(navigate);
const medals = ['🥇', '🥈', '🥉', ''];
return (
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6">
<h1 className="text-3xl font-bold">Koniec hry!</h1>
<div className="bg-slate-800 rounded-2xl w-full overflow-hidden">
{totals.map((p, i) => (
<div
key={p.order}
className="flex items-center justify-between px-5 py-3 border-b border-slate-700 last:border-0"
>
<div className="flex items-center gap-3">
<span className="text-2xl w-8">{medals[i]}</span>
<span className="font-semibold">{p.name}</span>
</div>
<span className="text-xl font-bold text-yellow-300">{p.total}</span>
</div>
))}
</div>
<button
onClick={handleLeave}
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold text-lg"
>
Domov
</button>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules';
import Hand from '../components/Hand';
import GuessControls from '../components/GuessControls';
import Trick from '../components/Trick';
import Standings from '../components/Standings';
import GameOver from './GameOver';
import type { StashData } from '../types';
const TRICK_LINGER_MS = 3000;
export default function GameTable() {
const navigate = useNavigate();
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
const hand = useGameStore((s) => s.hand);
// Hold the last completed trick visible for TRICK_LINGER_MS after it finishes.
const [lingeredStash, setLingeredStash] = useState<StashData | null>(null);
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousStash = gameStatus?.status.previous_stash ?? null;
useEffect(() => {
if (!previousStash) return;
setLingeredStash(previousStash);
if (lingerTimer.current) clearTimeout(lingerTimer.current);
lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS);
return () => {
if (lingerTimer.current) clearTimeout(lingerTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
if (!gameStatus || !myPlayer) {
return <p className="text-center text-gray-400 pt-20">Nacitava sa...</p>;
}
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
const {
active_player,
active_round_guesses,
active_round_stashes,
active_stash,
standings = [],
} = status;
if (completed) {
return <GameOver players={players} standings={standings} />;
}
const myOrder = myPlayer.order;
const isPlayPhase = active_stash !== undefined;
const myTurnToPlay = isPlayPhase && active_player === myOrder;
// Show active stash if it has cards; otherwise show the lingered completed trick.
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
const displayedStash = activeCards > 0 ? active_stash : lingeredStash ?? undefined;
// Highlight only cards the engine would accept
const playableKeys = myTurnToPlay && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined;
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
const handleLeave = () => leaveGame(navigate);
return (
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">
<span>Seria {series_number} / Kolo {round_number + 1}</span>
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
</div>
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
Odist
</button>
</div>
{/* Turn indicator */}
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center">
{active_player === myOrder ? (
<span className="text-yellow-300 font-semibold">
{!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'}
</span>
) : (
<span className="text-gray-400">
Na rade: <span className="text-white">{activePlayerName}</span>
</span>
)}
</div>
{/* Guesses summary (always shown when available) */}
{active_round_guesses && (
<div className="bg-slate-800/60 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 mb-1">Tipy:</p>
<div className="flex gap-3 flex-wrap">
{players.map((p) => {
const guess = active_round_guesses[String(p.order)];
const wins = active_round_stashes?.[p.order] ?? 0;
return (
<span key={p.order} className="text-sm">
<span className="text-gray-300">{p.name}:</span>{' '}
{guess !== undefined ? (
<span className="text-white font-semibold">
{isPlayPhase ? `${wins}/${guess}` : guess}
</span>
) : (
<span className="text-gray-500">?</span>
)}
</span>
);
})}
</div>
</div>
)}
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */}
{isPlayPhase && (
<Trick stash={displayedStash ?? null} />
)}
{/* Guess phase controls */}
{!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && (
<GuessControls
cardsInRound={cards_in_round}
guesses={active_round_guesses}
myOrder={myOrder}
activePlayer={active_player}
activePlayerName={activePlayerName}
/>
)}
{/* Hand */}
<div>
<p className="text-xs text-gray-400 mb-1 text-center">Tvoje karty</p>
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} />
</div>
{/* Standings */}
<Standings standings={standings} players={players} />
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
export default function Lobby() {
const { gid } = useParams<{ gid: string }>();
const navigate = useNavigate();
const myPlayer = useGameStore((s) => s.myPlayer);
const games = useGameStore((s) => s.games);
const game = games.find((g) => g.gid === gid);
const players = game?.players ?? [];
const isHost = myPlayer?.order === 0;
const canStart = players.length === 4 && isHost;
const handleLeave = () => {
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
};
const handleCopyCode = () => {
if (gid) navigator.clipboard.writeText(gid);
};
return (
<div className="max-w-md mx-auto p-4 pt-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold">{game?.name ?? 'Hra'}</h1>
<button onClick={handleLeave} className="text-sm text-gray-400 hover:text-white">
Odist
</button>
</div>
<div className="bg-slate-800 rounded-xl p-4 mb-4 flex items-center justify-between">
<div>
<p className="text-xs text-gray-400 mb-1">Kod hry</p>
<p className="font-mono text-sm text-gray-200 break-all">{gid}</p>
</div>
<button
onClick={handleCopyCode}
className="ml-3 px-3 py-1 rounded-lg text-sm bg-slate-700 hover:bg-slate-600"
>
Kopirovat
</button>
</div>
<div className="bg-slate-800 rounded-xl p-4 mb-6 flex flex-col gap-3">
{[0, 1, 2, 3].map((order) => {
const p = players.find((pl) => pl.order === order);
return (
<div key={order} className="flex items-center gap-3">
<span className={`text-lg ${p ? 'text-green-400' : 'text-gray-600'}`}>
{p ? '✓' : '○'}
</span>
<span className={p ? 'text-white' : 'text-gray-500 italic'}>
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'}
</span>
</div>
);
})}
</div>
<button
disabled={!canStart}
onClick={() => gid && emit.startGame(gid)}
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg disabled:opacity-40 disabled:cursor-default"
>
{isHost ? (canStart ? 'Zacat hru' : `Caka sa na hracov (${players.length}/4)`) : 'Caka sa na hosta...'}
</button>
</div>
);
}