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
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="sk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bridzik</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7115
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "bridzik-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+70
View File
@@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket';
import type { MyPlayer } from './types';
import GameList from './pages/GameList';
import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable';
function AppInner() {
const navigate = useNavigate();
const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus);
const error = useGameStore((s) => s.error);
const clearError = useGameStore((s) => s.clearError);
// Reconnect on every socket connect event (handles auto-reconnects after network drops)
useEffect(() => {
const doReconnect = () => {
const saved = localStorage.getItem('bridzik_player');
if (!saved) return;
const player = JSON.parse(saved) as MyPlayer;
emit.reconnectToGame(player.gid, player.token);
};
socket.on('connect', doReconnect);
if (socket.connected) doReconnect();
return () => { socket.off('connect', doReconnect); };
}, []);
// Single authoritative navigation: derive target from store state
const targetRoute = myPlayer
? gameStatus ? `/game/${gameStatus.gid}` : `/lobby/${myPlayer.gid}`
: null;
useEffect(() => {
if (!targetRoute) return;
navigate(targetRoute, { replace: true });
}, [targetRoute, navigate]);
// Auto-dismiss errors after 4 s
useEffect(() => {
if (!error) return;
const t = setTimeout(clearError, 4000);
return () => clearTimeout(t);
}, [error, clearError]);
return (
<>
{error && (
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm max-w-xs text-center">
{error}
</div>
)}
<Routes>
<Route path="/" element={<GameList />} />
<Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</>
);
}
export default function App() {
return (
<BrowserRouter>
<AppInner />
</BrowserRouter>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { Card } from '../types';
const SUIT_SYMBOL: Record<string, string> = {
HEARTS: '♥',
LEAVES: '♠',
ACORNS: '♣',
BELLS: '♦',
};
const SUIT_COLOR: Record<string, string> = {
HEARTS: '#c40000',
LEAVES: '#1e7a1e',
ACORNS: '#b87a00',
BELLS: '#0087b8',
};
const VALUE_LABEL: Record<string, string> = {
C7: 'VII', C8: 'VIII', C9: 'IX', C10: 'X',
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
};
interface Props {
card: Card;
onClick?: () => void;
disabled?: boolean;
selected?: boolean;
size?: 'sm' | 'md' | 'lg';
}
export default function CardView({ card, onClick, disabled = false, selected = false, size = 'md' }: Props) {
const symbol = SUIT_SYMBOL[card.color];
const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value];
const dims = {
sm: { cls: 'w-10 h-14', label: 9, iconSm: 9, iconLg: 18, inset: 2 },
md: { cls: 'w-14 h-20', label: 11, iconSm: 11, iconLg: 26, inset: 3 },
lg: { cls: 'w-20 h-28', label: 15, iconSm: 15, iconLg: 38, inset: 4 },
}[size];
const interactive = !disabled && !!onClick;
return (
<button
onClick={onClick}
disabled={disabled}
className={[
dims.cls,
'relative bg-white border rounded-md shadow-sm transition-transform overflow-hidden',
selected && !disabled ? 'border-yellow-400 -translate-y-2' : 'border-gray-300',
disabled ? 'opacity-50 cursor-default' : '',
interactive ? 'hover:-translate-y-1 cursor-pointer active:scale-95' : 'cursor-default',
].join(' ')}
>
<span
className="absolute pointer-events-none rounded"
style={{ inset: dims.inset, border: '0.5px solid #f0ece0' }}
/>
<span className="absolute top-1 left-1 flex flex-col items-center" style={{ gap: 1 }}>
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
</span>
<span className="absolute inset-0 flex items-center justify-center">
<span style={{ color, fontSize: dims.iconLg, lineHeight: 1 }}>{symbol}</span>
</span>
<span className="absolute bottom-1 right-1 flex flex-col items-center rotate-180" style={{ gap: 1 }}>
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
</span>
</button>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { emit } from '../lib/socket';
interface Props {
cardsInRound: number;
guesses: Record<string, number>;
myOrder: number;
activePlayer: number;
activePlayerName: string;
}
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
const guessCount = Object.keys(guesses).length;
const isMyTurn = activePlayer === myOrder;
const isLastToGuess = guessCount === 3;
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
const forbidden = isLastToGuess ? cardsInRound - alreadySum : -1;
if (!isMyTurn) {
return (
<p className="text-gray-400 text-sm text-center py-2">
Caka sa na tip: <span className="text-white font-semibold">{activePlayerName}</span>
</p>
);
}
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
return (
<div className="flex flex-col items-center gap-2 py-2">
<p className="text-sm text-gray-300">Tvoj tip (pocet kopok):</p>
<div className="flex flex-wrap gap-2 justify-center">
{options.map((n) => {
const isForbidden = n === forbidden;
return (
<button
key={n}
disabled={isForbidden}
onClick={() => emit.addGuess(n)}
title={isForbidden ? 'Zakázaná hodnota (suma = počet kopok)' : undefined}
className={[
'w-10 h-10 rounded-lg font-bold text-lg border-2 transition-colors',
isForbidden
? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed'
: 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95',
].join(' ')}
>
{n}
</button>
);
})}
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { CardColor, Hand } from '../types';
import CardView from './CardView';
import { emit } from '../lib/socket';
const COLOR_ORDER: CardColor[] = ['HEARTS', 'LEAVES', 'ACORNS', 'BELLS'];
const VALUE_ORDER = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
function groupedByColor(hand: Hand): { color: CardColor; keys: string[] }[] {
return COLOR_ORDER
.map((color) => ({
color,
keys: Object.keys(hand)
.filter((k) => hand[k].color === color)
.sort((a, b) => VALUE_ORDER.indexOf(hand[a].value) - VALUE_ORDER.indexOf(hand[b].value)),
}))
.filter((g) => g.keys.length > 0);
}
interface Props {
hand: Hand;
myTurn: boolean;
isPlayPhase: boolean;
playableKeys?: Set<string>;
}
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys }: Props) {
const groups = groupedByColor(hand);
if (groups.length === 0) return null;
return (
<div className="flex flex-wrap gap-3 justify-center py-2">
{groups.map(({ color, keys }) => (
<div key={color} className="flex gap-1">
{keys.map((key) => {
// During guessing phase cards are visible at full opacity (just not clickable).
// Only dim cards during the play phase when they can't be played.
const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key)));
return (
<CardView
key={key}
card={hand[key]}
disabled={disabled}
onClick={() => emit.playCard(key)}
/>
);
})}
</div>
))}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
interface Props {
mode: 'create' | 'join';
gid?: string;
onClose: () => void;
}
export default function NameModal({ mode, gid, onClose }: Props) {
const [name, setName] = useState(localStorage.getItem('bridzik_name') ?? '');
const myPlayer = useGameStore((s) => s.myPlayer);
// Close modal once we have a player identity
useEffect(() => {
if (myPlayer) onClose();
}, [myPlayer, onClose]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
localStorage.setItem('bridzik_name', trimmed);
if (mode === 'join' && gid) {
// emit.registerPlayer sets _pendingGid so the listener can resolve gid
emit.registerPlayer(gid, trimmed);
} else {
// emit.createGame stores the name; the socket listener auto-chains registerPlayer
emit.createGame(trimmed);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4">
<div className="bg-slate-800 rounded-2xl p-6 w-full max-w-sm shadow-xl">
<h2 className="text-lg font-bold mb-4 text-white">Zadaj svoje meno</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={20}
placeholder="Tvoje meno"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-gray-400 hover:text-white"
>
Zrusit
</button>
<button
type="submit"
disabled={!name.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40"
>
Potvrdit
</button>
</div>
</form>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
interface Props {
onClose: () => void;
}
export default function RulesModal({ onClose }: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="relative bg-slate-900 rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white text-xl leading-none"
>
</button>
<h1 className="text-xl font-bold mb-4">Pravidlá hry Bridžik</h1>
<Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
<p className="mt-2"><b>Farby:</b> červeň (), zeleň (), žaluď (), guľa ()</p>
<p className="mt-1 text-red-400 font-semibold">Červeň je vždy tromf (adut) prebíja každú inú farbu.</p>
<p className="mt-2"><b>Hodnoty</b> od najnižšej: VII · VIII · IX · X · J · Q · K · A</p>
</Section>
<Section title="Štruktúra hry">
<p>4 hráči · 4 série · 8 kôl v sérii</p>
<p className="mt-1">V každom kole dostane každý hráč <b>8 číslo_kola</b> kariet (8 1).</p>
<p className="mt-1">Sériu otvára hráč s rovnakým číslom ako séria. Každé ďalšie kolo posúva začínajúceho hráča o jedného.</p>
</Section>
<Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p>
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 počet kopiek).</p>
<p className="mt-1 text-yellow-300">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="font-semibold mt-3">2. Hranie kariet</p>
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
<p className="font-semibold mt-3">Povinnosť priznať farbu:</p>
<ol className="mt-1 list-decimal list-inside space-y-1">
<li>Máš farbu vynesenej karty <b>musíš ju zahrať.</b></li>
<li>Nemáš ju, ale máš červeň <b>musíš zahrať červeň.</b></li>
<li>Nemáš ani jedno môžeš zahrať <b>ľubovoľnú</b> kartu.</li>
</ol>
<p className="font-semibold mt-3">Víťaz kopky:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>Ak padla červeň vyhráva <b>najvyššia červeň.</b></li>
<li>Ak nie vyhráva <b>najvyššia karta vynesenej farby.</b></li>
</ul>
</Section>
<Section title="Bodovanie">
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek <b>10 + tip</b> bodov, inak <b>0</b>.</p>
<p className="mt-1 text-gray-400">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section>
<button
onClick={onClose}
className="mt-4 w-full py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 font-semibold"
>
Zavrieť
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h2 className="font-bold text-base text-green-400 mb-1">{title}</h2>
<div className="text-gray-200">{children}</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useState } from 'react';
import type { PlayerInfo } from '../types';
import { computeTotal } from '../lib/standings';
interface Props {
standings: number[][][];
players: PlayerInfo[];
}
export default function Standings({ standings, players }: Props) {
const [open, setOpen] = useState(false);
const sorted = [...players]
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
.sort((a, b) => b.total - a.total);
return (
<div className="bg-slate-800 rounded-xl overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex justify-between items-center px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-slate-700"
>
<span>Skore</span>
<span>{open ? '▲' : '▼'}</span>
</button>
{open && (
<table className="w-full text-sm text-center">
<thead>
<tr className="text-gray-400 border-b border-slate-700">
<th className="py-1 px-2 text-left">Hrac</th>
{standings.map((_, si) => (
<th key={si} className="py-1 px-2">S{si + 1}</th>
))}
<th className="py-1 px-2">Spolu</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => (
<tr key={p.order} className="border-b border-slate-700/50">
<td className="py-1 px-2 text-left text-gray-200">{p.name}</td>
{standings.map((series, si) => {
return (
<td key={si} className="py-1 px-2 text-gray-300">
{computeTotal([series], p.order)}
</td>
);
})}
<td className="py-1 px-2 font-bold text-white">{p.total}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { StashData } from '../types';
import CardView from './CardView';
interface Props {
stash: StashData | null;
}
export default function Trick({ stash }: Props) {
const cards = stash
? [0, 1, 2, 3]
.map((i) => (stash.first_player + i) % 4)
.map((order) => stash.cards[String(order)])
.filter(Boolean)
: [];
return (
<div className="bg-green-900/60 rounded-xl p-4">
<p className="text-xs text-green-300 mb-3 text-center">Aktualny stich</p>
<div className="flex gap-3 justify-center min-h-28">
{cards.map((card, i) => (
<CardView key={i} card={card} size="lg" />
))}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-900 text-white min-h-screen;
}
+14
View File
@@ -0,0 +1,14 @@
import type { CardColor, Hand } from '../types';
export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<string> {
const keys = Object.keys(hand);
if (!ledColor) return new Set(keys);
const ledKeys = keys.filter((k) => hand[k].color === ledColor);
if (ledKeys.length > 0) return new Set(ledKeys);
const heartKeys = keys.filter((k) => hand[k].color === 'HEARTS');
if (heartKeys.length > 0) return new Set(heartKeys);
return new Set(keys);
}
+10
View File
@@ -0,0 +1,10 @@
import { NavigateFunction } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit } from './socket';
export function leaveGame(navigate: NavigateFunction) {
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
}
+69
View File
@@ -0,0 +1,69 @@
import { io } from 'socket.io-client';
import { useGameStore } from '../store/gameStore';
import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types';
export const socket = io({ autoConnect: false });
// Module-level state for the create→register chain and gid resolution on register_player
let _pendingGid: string | null = null;
let _createName: string | null = null;
export const emit = {
createGame: (name: string) => {
_createName = name;
socket.emit('create_game', name);
},
registerPlayer: (gid: string, name: string) => {
_pendingGid = gid;
socket.emit('register_player', gid, name);
},
leaveGame: () => socket.emit('leave_game'),
startGame: (gid: string) => socket.emit('start_game', gid),
reconnectToGame: (gid: string, token: string) => socket.emit('reconnect_to_game', gid, token),
gameStatus: () => socket.emit('game_status'),
playerCards: () => socket.emit('player_cards'),
addGuess: (guess: number) => socket.emit('add_guess', guess),
playCard: (cardKey: string) => socket.emit('play_card', cardKey),
};
export function setupSocketListeners() {
socket.on('get_games', ({ games }: { games: GameInfo[] }) => {
useGameStore.getState().setGames(games);
});
socket.on('create_game', ({ gid }: { gid: string }) => {
// Auto-chain: register into the just-created game using the stored name
if (_createName !== null) {
emit.registerPlayer(gid, _createName);
_createName = null;
}
});
socket.on(
'register_player',
({ player, token }: { player: { order: number; name: string }; token: string }) => {
const saved = localStorage.getItem('bridzik_player');
const gid = _pendingGid ?? (saved ? (JSON.parse(saved) as MyPlayer).gid : '');
_pendingGid = null;
const myPlayer: MyPlayer = { ...player, token, gid };
useGameStore.getState().setMyPlayer(myPlayer);
localStorage.setItem('bridzik_player', JSON.stringify(myPlayer));
}
);
socket.on('game_status', (payload: GameStatusPayload) => {
useGameStore.getState().setGameStatus(payload);
});
socket.on('player_cards', ({ cards }: { cards: Hand }) => {
useGameStore.getState().setHand(cards);
});
socket.on('player_connection', ({ order, connected }: { order: number; connected: boolean }) => {
useGameStore.getState().updatePlayerConnection(order, connected);
});
socket.on('error', ({ error }: { error: string }) => {
useGameStore.getState().setError(error);
});
}
+3
View File
@@ -0,0 +1,3 @@
export function computeTotal(standings: number[][][], playerOrder: number): number {
return standings.flat().reduce((sum, round) => sum + (round[playerOrder] ?? 0), 0);
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { setupSocketListeners, socket } from './lib/socket';
setupSocketListeners();
socket.connect();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+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>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import type { GameInfo, GameStatusPayload, Hand, MyPlayer } from '../types';
interface GameStore {
games: GameInfo[];
myPlayer: MyPlayer | null;
gameStatus: GameStatusPayload | null;
hand: Hand;
error: string | null;
setGames: (games: GameInfo[]) => void;
setMyPlayer: (player: MyPlayer | null) => void;
setGameStatus: (status: GameStatusPayload) => void;
setHand: (hand: Hand) => void;
setError: (error: string | null) => void;
clearError: () => void;
updatePlayerConnection: (order: number, connected: boolean) => void;
reset: () => void;
}
export const useGameStore = create<GameStore>((set) => ({
games: [],
myPlayer: null,
gameStatus: null,
hand: {},
error: null,
setGames: (games) => set({ games }),
setMyPlayer: (myPlayer) => set({ myPlayer }),
setGameStatus: (gameStatus) => set({ gameStatus }),
setHand: (hand) => set({ hand }),
setError: (error) => set({ error }),
clearError: () => set({ error: null }),
updatePlayerConnection: (order, connected) =>
set((state) => ({
gameStatus: state.gameStatus
? {
...state.gameStatus,
players: state.gameStatus.players.map((p) =>
p.order === order ? { ...p, connected } : p
),
}
: null,
})),
reset: () => set({ myPlayer: null, gameStatus: null, hand: {}, error: null }),
}));
+53
View File
@@ -0,0 +1,53 @@
export type CardColor = 'HEARTS' | 'LEAVES' | 'ACORNS' | 'BELLS';
export type CardValue = 'C7' | 'C8' | 'C9' | 'C10' | 'LOWER' | 'UPPER' | 'KING' | 'ACE';
export interface Card {
color: CardColor;
value: CardValue;
}
export interface PlayerInfo {
order: number;
name: string;
connected: boolean;
}
export interface MyPlayer {
order: number;
name: string;
token: string;
gid: string;
}
export interface StashData {
first_player: number;
cards: Record<string, Card>;
}
export interface GameStatusDetail {
active_player?: number;
active_round_guesses?: Record<string, number>;
active_round_stashes?: number[];
active_stash?: StashData;
previous_stash?: StashData;
standings: number[][][];
}
export interface GameStatusPayload {
gid: string;
completed: boolean;
players: PlayerInfo[];
series_number: number;
round_number: number;
cards_in_round: number;
status: GameStatusDetail;
}
export interface GameInfo {
gid: string;
name: string;
started: boolean;
players: PlayerInfo[];
}
export type Hand = Record<string, Card>;
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Bridzik',
short_name: 'Bridzik',
theme_color: '#1e3a5f',
background_color: '#0f172a',
display: 'standalone',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
}),
],
server: {
host: true,
proxy: {
'/socket.io': {
target: 'http://backend:5000',
ws: true,
changeOrigin: true,
},
},
},
});