Add React frontend and clean up legacy HTTP backend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user