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
+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>
);
}