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