Apply velvet-table redesign, fix game lifecycle and history bugs
Frontend: - Dark green/gold "velvet table" visual redesign across the whole app (Auth, Lobby, GameList, GameTable, History, GameOver, modals), with Playfair Display/DM Sans typography and a centralized Tailwind palette. - Desktop game table fit-scales to fill the window; mobile gets overlapping hand/trick layouts and larger touch-friendly cards. - Standings sidebar now groups completed rounds by series with a per-series subtotal row, struck-through tips on missed bids. - History page rewritten into a scoreboard-style detail view (player totals beside names, series grouped 2-up on desktop / stacked on mobile) and gained game names, completed/abandoned status, and a button to reopen a prematurely-ended game back into the lobby. Backend: - Fix started games being deleted from memory (and vanishing from everyone's lobby) when all players disconnect; only `end_game` tears down a started game now. - Fix a crash writing a timezone-aware datetime into the naive `ended_at` Postgres column. - Add `reopen_game`/`restore_game` to un-end a prematurely-ended game from history and resume it from the lobby. - Let any seated player end an abandoned game once the host is offline, not just the host, so the game isn't stuck forever. - Expose SERIES_PER_GAME/ROUNDS_PER_SERIES as named constants on the engine so the persistence layer derives game-completion rules from bridzik.py instead of re-encoding them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+49
-30
@@ -2,13 +2,18 @@ import { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
|
||||
const inputCls =
|
||||
'bg-circle text-green-score rounded-lg px-4 py-2 border border-gold/20 outline-none focus:border-gold/60 focus:ring-1 focus:ring-gold/30 placeholder:text-green-dim/60';
|
||||
|
||||
export default function Auth() {
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
|
||||
const [code, setCode] = useState('');
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const registration = useGameStore((s) => s.registration);
|
||||
const setRegistration = useGameStore((s) => s.setRegistration);
|
||||
|
||||
@@ -41,28 +46,28 @@ export default function Auth() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto p-4 pt-10">
|
||||
<h1 className="text-2xl font-bold text-center mb-2 tracking-wide">Bridzik</h1>
|
||||
<p className="text-center text-gray-400 text-sm mb-6">
|
||||
Prihlas sa kodom z aplikacie (napr. Google Authenticator).
|
||||
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
||||
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
||||
<p className="text-center text-green-dim text-sm mb-7">
|
||||
Prihlás sa kódom z aplikácie (napr. Google Authenticator).
|
||||
</p>
|
||||
|
||||
<div className="flex mb-6 rounded-xl overflow-hidden border border-slate-700">
|
||||
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
|
||||
<button
|
||||
onClick={() => switchMode('login')}
|
||||
className={`flex-1 py-2 text-sm font-semibold ${
|
||||
mode === 'login' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
|
||||
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
|
||||
mode === 'login' ? 'bg-gold text-table' : 'bg-header text-green-dim'
|
||||
}`}
|
||||
>
|
||||
Prihlasenie
|
||||
Prihlásenie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchMode('register')}
|
||||
className={`flex-1 py-2 text-sm font-semibold ${
|
||||
mode === 'register' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400'
|
||||
className={`flex-1 py-2 text-sm font-serif tracking-wide ${
|
||||
mode === 'register' ? 'bg-gold text-table' : 'bg-header text-green-dim'
|
||||
}`}
|
||||
>
|
||||
Registracia
|
||||
Registrácia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -74,8 +79,8 @@ export default function Auth() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
maxLength={40}
|
||||
placeholder="Pouzivatelske meno"
|
||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Používateľské meno"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -83,15 +88,15 @@ export default function Auth() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
maxLength={6}
|
||||
placeholder="6-miestny kod"
|
||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono"
|
||||
placeholder="6-miestny kód"
|
||||
className={`${inputCls} tracking-widest font-mono`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || code.trim().length < 6}
|
||||
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
|
||||
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Prihlasit
|
||||
Prihlásiť
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -104,30 +109,30 @@ export default function Auth() {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
maxLength={40}
|
||||
placeholder="Zvol si pouzivatelske meno"
|
||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Zvoľ si používateľské meno"
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim()}
|
||||
className="py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold disabled:opacity-40"
|
||||
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Vytvorit ucet
|
||||
Vytvoriť účet
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === 'register' && registration && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-300">
|
||||
Naskenuj QR kod do autentifikacnej aplikacie a opis aktualny kod.
|
||||
<p className="text-sm text-green-score">
|
||||
Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.
|
||||
</p>
|
||||
<div className="bg-white rounded-xl p-4 flex justify-center">
|
||||
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 text-center">
|
||||
Alebo zadaj rucne kluc:
|
||||
<span className="block font-mono text-gray-200 break-all mt-1">
|
||||
<div className="text-xs text-green-dim text-center">
|
||||
Alebo zadaj ručne kľúč:
|
||||
<span className="block font-mono text-green-score break-all mt-1">
|
||||
{registration.secret}
|
||||
</span>
|
||||
</div>
|
||||
@@ -139,19 +144,33 @@ export default function Auth() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
maxLength={6}
|
||||
placeholder="6-miestny kod"
|
||||
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono"
|
||||
placeholder="6-miestny kód"
|
||||
className={`${inputCls} tracking-widest font-mono`}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={code.trim().length < 6}
|
||||
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40"
|
||||
className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Potvrdit a prihlasit
|
||||
Potvrdiť a prihlásiť
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules are reachable before login — quiet link with the velvet divider motif. */}
|
||||
<div className="mt-10 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
|
||||
>
|
||||
Pravidlá hry
|
||||
</button>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-gold/20" />
|
||||
</div>
|
||||
|
||||
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,23 +24,23 @@ export default function GameList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-wide">Bridzik</h1>
|
||||
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-400">{account?.username}</span>
|
||||
<button onClick={() => navigate('/history')} className="text-blue-400 hover:text-blue-300">
|
||||
Historia
|
||||
<span className="text-green-dim">{account?.username}</span>
|
||||
<button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
|
||||
História
|
||||
</button>
|
||||
<button onClick={handleLogout} className="text-gray-400 hover:text-white">
|
||||
Odhlasit
|
||||
<button onClick={handleLogout} className="text-green-dim hover:text-gold">
|
||||
Odhlásiť
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-center text-green-dim py-4">Žiadne hry. Vytvor prvú!</p>
|
||||
)}
|
||||
{games.map((g) => {
|
||||
const full = g.players.length >= 4;
|
||||
@@ -48,24 +48,26 @@ export default function GameList() {
|
||||
!!account && g.players.some((p) => p.player_id === account.player_id);
|
||||
const canResume = g.started && isMember;
|
||||
const unavailable = !canResume && (full || g.started);
|
||||
const label = canResume ? 'Pokracovat' : full ? 'Plna' : g.started ? 'Zacata' : 'Vstup';
|
||||
const label = canResume ? 'Pokračovať' : full ? 'Plná' : g.started ? 'Začatá' : 'Vstúp';
|
||||
return (
|
||||
<div
|
||||
key={g.gid}
|
||||
className="flex items-center justify-between bg-slate-800 rounded-xl px-4 py-3"
|
||||
className="flex items-center justify-between bg-header border border-[#142018] 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 className="font-serif text-green-score">{g.name}</p>
|
||||
<p className="text-xs text-green-dim">
|
||||
{g.players.length}/4 hráčov
|
||||
{g.started ? ' · začatá' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={unavailable}
|
||||
onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold disabled:opacity-40 disabled:cursor-default ${
|
||||
canResume ? 'bg-green-700 hover:bg-green-600' : 'bg-blue-600 hover:bg-blue-500'
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-serif font-semibold disabled:opacity-40 disabled:cursor-default transition-colors ${
|
||||
canResume
|
||||
? 'bg-gold text-table hover:bg-gold-bright'
|
||||
: 'border border-gold/40 text-gold hover:bg-gold hover:text-table'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -77,14 +79,14 @@ export default function GameList() {
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg mb-3"
|
||||
className="w-full py-2 rounded-xl bg-gold text-table font-serif font-semibold text-base mb-3 hover:bg-gold-bright transition-colors"
|
||||
>
|
||||
+ Vytvorit novu hru
|
||||
Vytvoriť novú 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"
|
||||
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
|
||||
>
|
||||
Pravidlá hry
|
||||
</button>
|
||||
|
||||
@@ -20,25 +20,27 @@ export default function GameOver({ players, standings }: Props) {
|
||||
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">
|
||||
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6 min-h-screen">
|
||||
<h1 className="font-serif text-3xl text-gold tracking-wide">Koniec hry</h1>
|
||||
<div className="w-full rounded-2xl overflow-hidden bg-header border border-[#142018]">
|
||||
{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"
|
||||
className="flex items-center justify-between px-5 py-3 border-b border-gold/[.08] 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>
|
||||
<span className="font-serif text-green-score">{p.name}</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-yellow-300">{p.total}</span>
|
||||
<span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
|
||||
{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"
|
||||
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg hover:bg-gold-bright transition-colors"
|
||||
>
|
||||
Domov
|
||||
</button>
|
||||
|
||||
@@ -4,17 +4,26 @@ import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
import { computePlayable } from '../lib/gameRules';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import { useFitScale } from '../lib/useFitScale';
|
||||
import Hand from '../components/Hand';
|
||||
import GuessControls from '../components/GuessControls';
|
||||
import Trick from '../components/Trick';
|
||||
import Standings from '../components/Standings';
|
||||
import PlayerCircle from '../components/PlayerCircle';
|
||||
import FaceDownCards from '../components/FaceDownCards';
|
||||
import GameOver from './GameOver';
|
||||
import type { StashData } from '../types';
|
||||
import type { PlayerInfo, StashData } from '../types';
|
||||
|
||||
const TRICK_LINGER_MS = 3000;
|
||||
|
||||
export default function GameTable() {
|
||||
const navigate = useNavigate();
|
||||
const desktop = useIsDesktop();
|
||||
// Zooms the whole desktop board to fill the window (full width + height), so
|
||||
// cards, circles, text and the header all scale together. Up to 2.6×.
|
||||
const { containerRef, scale, contentWidth } = useFitScale([desktop], 860, 2.6);
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
const gameStatus = useGameStore((s) => s.gameStatus);
|
||||
const hand = useGameStore((s) => s.hand);
|
||||
@@ -33,11 +42,11 @@ export default function GameTable() {
|
||||
return () => {
|
||||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// 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>;
|
||||
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||||
}
|
||||
|
||||
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
|
||||
@@ -47,6 +56,7 @@ export default function GameTable() {
|
||||
active_round_stashes,
|
||||
active_stash,
|
||||
standings = [],
|
||||
standings_guesses = [],
|
||||
} = status;
|
||||
|
||||
if (completed) {
|
||||
@@ -57,106 +67,280 @@ export default function GameTable() {
|
||||
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;
|
||||
const displayedStash: StashData | null =
|
||||
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null;
|
||||
|
||||
// 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 ?? '';
|
||||
|
||||
// Seat mapping relative to "Ty": left / across / right.
|
||||
const seat = (offset: number): PlayerInfo | undefined =>
|
||||
players.find((p) => p.order === (myOrder + offset) % 4);
|
||||
const leftP = seat(1);
|
||||
const topP = seat(2);
|
||||
const rightP = seat(3);
|
||||
|
||||
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0);
|
||||
const guessOf = (o?: number): number | null => {
|
||||
if (o === undefined) return null;
|
||||
const g = active_round_guesses?.[String(o)];
|
||||
return g === undefined ? null : g;
|
||||
};
|
||||
const activeOf = (o?: number) => o !== undefined && active_player === o;
|
||||
|
||||
// Exact cards still in a player's hand: started with cards_in_round, lost one
|
||||
// per completed trick, minus one more if they've already played this trick.
|
||||
const completedTricks = (active_round_stashes ?? []).reduce((a, b) => a + b, 0);
|
||||
const cardsInHandOf = (o?: number) => {
|
||||
if (o === undefined) return 0;
|
||||
const playedCurrent = active_stash?.cards[String(o)] ? 1 : 0;
|
||||
return Math.max(0, cards_in_round - completedTricks - playedCurrent);
|
||||
};
|
||||
|
||||
const handleLeave = () => leaveGame(navigate);
|
||||
const handleEnd = () => {
|
||||
if (window.confirm('Naozaj ukoncit celu hru pre vsetkych?')) {
|
||||
if (window.confirm('Naozaj ukončiť celú hru pre všetkých?')) {
|
||||
emit.endGame(gameStatus.gid);
|
||||
}
|
||||
};
|
||||
// The host can always end the game; other players only when the host is
|
||||
// currently offline, so an abandoned game isn't stuck forever waiting for
|
||||
// a host who won't come back, but it isn't open to casual misuse otherwise.
|
||||
const hostConnected = players.find((p) => p.order === 0)?.connected ?? false;
|
||||
const canEnd = myOrder === 0 || !hostConnected;
|
||||
|
||||
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>
|
||||
<div className="flex gap-3">
|
||||
{myOrder === 0 && (
|
||||
<button onClick={handleEnd} className="text-xs text-red-400 hover:text-red-300">
|
||||
Ukoncit hru
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
|
||||
Odist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
// ── shared pieces ────────────────────────────────────────────────
|
||||
const bannerText = activeOf(myOrder)
|
||||
? isPlayPhase
|
||||
? 'Zahraj kartu'
|
||||
: 'Zadaj tip'
|
||||
: `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
|
||||
|
||||
{/* 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>
|
||||
const banner = (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
|
||||
<span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</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>
|
||||
);
|
||||
})}
|
||||
const opponents = players
|
||||
.filter((p) => p.order !== myOrder)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const totalsRow = (compact: boolean) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{opponents.map((p) => (
|
||||
<div key={p.order} className="text-center">
|
||||
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||
{computeTotal(standings, p.order)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
|
||||
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
|
||||
{myPlayer.name}
|
||||
</div>
|
||||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||
{computeTotal(standings, myOrder)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */}
|
||||
{isPlayPhase && (
|
||||
<Trick stash={displayedStash ?? null} />
|
||||
)}
|
||||
// Center of the oval: trick during play, guess controls during bidding.
|
||||
const ovalContent = isPlayPhase ? (
|
||||
<Trick stash={displayedStash} players={players} myOrder={myOrder} />
|
||||
) : (
|
||||
active_round_guesses !== undefined && active_player !== undefined ? (
|
||||
<GuessControls
|
||||
cardsInRound={cards_in_round}
|
||||
guesses={active_round_guesses}
|
||||
myOrder={myOrder}
|
||||
activePlayer={active_player}
|
||||
activePlayerName={activePlayerName}
|
||||
/>
|
||||
) : 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}
|
||||
/>
|
||||
)}
|
||||
const topSeat = (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle
|
||||
name={topP?.name ?? '—'}
|
||||
won={wonOf(topP?.order)}
|
||||
guess={guessOf(topP?.order)}
|
||||
active={activeOf(topP?.order)}
|
||||
size={desktop ? 64 : 52}
|
||||
/>
|
||||
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* 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} />
|
||||
const sideSeat = (p?: PlayerInfo) => (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle
|
||||
name={p?.name ?? '—'}
|
||||
won={wonOf(p?.order)}
|
||||
guess={guessOf(p?.order)}
|
||||
active={activeOf(p?.order)}
|
||||
size={desktop ? 60 : 48}
|
||||
/>
|
||||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meSeat = (
|
||||
<div className="flex justify-center">
|
||||
<PlayerCircle
|
||||
name={myPlayer.name}
|
||||
won={wonOf(myOrder)}
|
||||
guess={guessOf(myOrder)}
|
||||
active={activeOf(myOrder)}
|
||||
size={desktop ? 70 : 58}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handArea = (
|
||||
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
|
||||
);
|
||||
|
||||
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
|
||||
if (desktop) {
|
||||
return (
|
||||
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
|
||||
{/* Design canvas — fixed height, width spans the viewport; scaled as one
|
||||
unit so the whole board (and header) zooms with the window. */}
|
||||
<div
|
||||
className="flex"
|
||||
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
{/* main */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* header */}
|
||||
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
|
||||
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
|
||||
Bridžik
|
||||
</span>
|
||||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
|
||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||
</span>
|
||||
<div className="flex-1 flex items-center justify-center">{banner}</div>
|
||||
{totalsRow(true)}
|
||||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||||
Odísť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* game content — players hug the edges so the felt uses full width */}
|
||||
<div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
|
||||
{topSeat}
|
||||
|
||||
<div className="flex items-center justify-center gap-16">
|
||||
{sideSeat(leftP)}
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
width: 620,
|
||||
height: 372,
|
||||
background:
|
||||
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||||
boxShadow:
|
||||
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
|
||||
}}
|
||||
>
|
||||
{ovalContent}
|
||||
</div>
|
||||
{sideSeat(rightP)}
|
||||
</div>
|
||||
|
||||
{meSeat}
|
||||
</div>
|
||||
|
||||
{handArea}
|
||||
</div>
|
||||
|
||||
{/* sidebar */}
|
||||
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MOBILE LAYOUT ────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
|
||||
{/* header */}
|
||||
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
|
||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
|
||||
Odísť
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{totalsRow(false)}
|
||||
</div>
|
||||
|
||||
{/* Standings */}
|
||||
<Standings standings={standings} players={players} />
|
||||
{/* turn banner */}
|
||||
<div
|
||||
className="py-[9px] px-4 border-b border-[#152a1a]"
|
||||
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
|
||||
>
|
||||
{banner}
|
||||
</div>
|
||||
|
||||
{/* game area */}
|
||||
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
|
||||
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
minHeight: 192,
|
||||
background:
|
||||
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
|
||||
boxShadow:
|
||||
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
|
||||
}}
|
||||
>
|
||||
{ovalContent}
|
||||
</div>
|
||||
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5">{meSeat}</div>
|
||||
</div>
|
||||
|
||||
{handArea}
|
||||
|
||||
{/* score */}
|
||||
<div className="bg-table px-3 pb-4 pt-1">
|
||||
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+249
-54
@@ -1,7 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Fragment, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { emit, socket } from '../lib/socket';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import type { GameDetail, GameDetailRound } from '../types';
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
@@ -20,78 +22,271 @@ export default function History() {
|
||||
return () => setGameDetail(null);
|
||||
}, [setGameDetail]);
|
||||
|
||||
// After a prematurely-ended game is reopened, the server confirms with
|
||||
// `game_restored`; jump to the lobby where it now shows as resumable.
|
||||
useEffect(() => {
|
||||
const onRestored = () => navigate('/');
|
||||
socket.on('game_restored', onRestored);
|
||||
return () => {
|
||||
socket.off('game_restored', onRestored);
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// --- detail view ---
|
||||
if (detail) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<button
|
||||
onClick={() => setGameDetail(null)}
|
||||
className="text-sm text-gray-400 hover:text-white mb-4"
|
||||
>
|
||||
← Spat na zoznam
|
||||
</button>
|
||||
<h1 className="text-xl font-bold mb-1">Detail hry</h1>
|
||||
<p className="text-xs text-gray-400 mb-4">{fmtDate(detail.created_at)}</p>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl overflow-hidden text-sm">
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-2 text-xs text-gray-400 border-b border-slate-700">
|
||||
<span>S/K</span>
|
||||
<span>Hrac</span>
|
||||
<span className="text-right">Tip</span>
|
||||
<span className="text-right">Body</span>
|
||||
</div>
|
||||
{detail.rounds.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-1.5 border-b border-slate-700/50 last:border-0"
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
{r.series_number}/{r.round_number}
|
||||
</span>
|
||||
<span className="truncate">{r.username ?? `#${r.player_id}`}</span>
|
||||
<span className="text-right font-mono">{r.guess}</span>
|
||||
<span className={`text-right font-mono ${r.won ? 'text-green-400' : 'text-gray-500'}`}>
|
||||
{r.points}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <GameDetailView detail={detail} onBack={() => setGameDetail(null)} />;
|
||||
}
|
||||
|
||||
// --- list view ---
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-xl font-bold">Moja historia</h1>
|
||||
<button onClick={() => navigate('/')} className="text-sm text-gray-400 hover:text-white">
|
||||
Spat
|
||||
<h1 className="font-serif text-2xl text-gold">Moja história</h1>
|
||||
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
|
||||
Späť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-6">Zatial ziadne odohrane hry.</p>
|
||||
<p className="text-center text-green-dim py-6">Zatiaľ žiadne odohrané hry.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{history.map((g) => (
|
||||
<button
|
||||
<div
|
||||
key={g.gid}
|
||||
onClick={() => emit.getGameDetail(g.gid)}
|
||||
className="text-left bg-slate-800 hover:bg-slate-700 rounded-xl px-4 py-3"
|
||||
className="flex items-stretch bg-header border border-[#142018] rounded-xl overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-200">{fmtDate(g.created_at)}</span>
|
||||
<span className="text-sm font-semibold text-green-400">{g.my_points} b.</span>
|
||||
<button
|
||||
onClick={() => emit.getGameDetail(g.gid)}
|
||||
className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
|
||||
>
|
||||
<p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
|
||||
<p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p>
|
||||
<p className="text-xs text-[#7a7058] mt-0.5">
|
||||
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-col items-end justify-center gap-2 py-3 pl-2 pr-3">
|
||||
<span className="text-base font-serif text-gold whitespace-nowrap leading-none">
|
||||
{g.my_points}
|
||||
<span className="text-[11px] text-green-dim ml-0.5">b.</span>
|
||||
</span>
|
||||
{!g.completed && (
|
||||
<button
|
||||
onClick={() => emit.restoreGame(g.gid)}
|
||||
className="px-4 py-1.5 rounded-lg text-sm font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors whitespace-nowrap"
|
||||
>
|
||||
Obnoviť
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1 truncate">{g.players.join(', ')}</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
{g.ended_at ? 'dohrana' : 'nedohrana'}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SEP = '1px solid rgba(201,168,76,.16)'; // vertical divider between players
|
||||
const DESKTOP_COLS = '28px repeat(8,1fr)'; // index + 4 players × 2 series columns
|
||||
const MOBILE_COLS = '28px repeat(4,1fr)'; // index + 4 players (one series stacked)
|
||||
|
||||
/** Scoreboard-style detail. Desktop: 4 player columns in the header (total
|
||||
* beside the name), and under each a pair of series side by side — series 1 &
|
||||
* 2 on top, 3 & 4 below, separated by a blank row. Mobile: the same 4 player
|
||||
* columns, but the series stack one below another. Per round a cell shows the
|
||||
* points (hit bid) or the struck-through bid (missed → 0); each series ends
|
||||
* with a Σ total. */
|
||||
function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () => void }) {
|
||||
const desktop = useIsDesktop();
|
||||
const seats = detail.players; // seat order 0..3
|
||||
const seatIds = seats.map((p) => p.player_id);
|
||||
const totals = seatIds.map((pid) =>
|
||||
detail.rounds.reduce((a, r) => (r.player_id === pid ? a + r.points : a), 0),
|
||||
);
|
||||
|
||||
// series -> round -> playerId -> round entry
|
||||
const bySeries = useMemo(() => {
|
||||
const m = new Map<number, Map<number, Map<number, GameDetailRound>>>();
|
||||
for (const r of detail.rounds) {
|
||||
let rounds = m.get(r.series_number);
|
||||
if (!rounds) m.set(r.series_number, (rounds = new Map()));
|
||||
let byPlayer = rounds.get(r.round_number);
|
||||
if (!byPlayer) rounds.set(r.round_number, (byPlayer = new Map()));
|
||||
byPlayer.set(r.player_id, r);
|
||||
}
|
||||
return m;
|
||||
}, [detail.rounds]);
|
||||
const seriesNums = [...bySeries.keys()].sort((a, b) => a - b);
|
||||
const roundsOf = (s: number | undefined) =>
|
||||
s === undefined ? [] : [...(bySeries.get(s)?.keys() ?? [])];
|
||||
|
||||
const cellNode = (s: number | undefined, rn: number, seat: number) => {
|
||||
const r = s === undefined ? undefined : bySeries.get(s)?.get(rn)?.get(seatIds[seat]);
|
||||
if (!r) return null;
|
||||
return r.won ? (
|
||||
<span className="font-serif" style={{ fontSize: 14, color: '#c8bb95' }}>{r.points}</span>
|
||||
) : (
|
||||
<span className="font-serif line-through" style={{ fontSize: 14, color: '#7a6e4a' }}>{r.guess}</span>
|
||||
);
|
||||
};
|
||||
const seriesTotal = (s: number | undefined, seat: number) =>
|
||||
s === undefined
|
||||
? ''
|
||||
: [...(bySeries.get(s)?.values() ?? [])].reduce(
|
||||
(a, byP) => a + (byP.get(seatIds[seat])?.points ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Header: player names with their grand total beside the name (both layouts).
|
||||
const header = (
|
||||
<div
|
||||
className="border-b border-gold/[.18]"
|
||||
style={{ display: 'grid', gridTemplateColumns: MOBILE_COLS, padding: '9px 8px' }}
|
||||
>
|
||||
<div />
|
||||
{seats.map((p, c) => (
|
||||
<div
|
||||
key={c}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
borderLeft: c > 0 ? SEP : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
|
||||
{p.username}
|
||||
</span>
|
||||
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
|
||||
{totals[c]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// A round row: index column + one cell per (player × series) in `cols`.
|
||||
const roundRow = (rn: number, cols: (number | undefined)[]) => (
|
||||
<div
|
||||
key={rn}
|
||||
className="border-b border-gold/[.04]"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
|
||||
alignItems: 'center',
|
||||
padding: '3px 8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', fontSize: 10, color: '#7a7252' }}>{rn + 1}</div>
|
||||
{seats.map((_, c) =>
|
||||
cols.map((s, si) => (
|
||||
<div key={`${c}-${si}`} style={{ textAlign: 'center', borderLeft: c > 0 && si === 0 ? SEP : undefined }}>
|
||||
{cellNode(s, rn, c)}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Σ row: per-series totals for each player.
|
||||
const sigmaRow = (cols: (number | undefined)[]) => (
|
||||
<div
|
||||
className="bg-gold/[.08] border-b border-gold/[.14]"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
|
||||
alignItems: 'center',
|
||||
padding: '5px 8px',
|
||||
}}
|
||||
>
|
||||
<div className="text-green-dim" style={{ textAlign: 'center', fontSize: 11 }}>Σ</div>
|
||||
{seats.map((_, c) =>
|
||||
cols.map((s, si) => (
|
||||
<div
|
||||
key={`${c}-${si}`}
|
||||
className="font-serif text-green-score"
|
||||
style={{ textAlign: 'center', fontWeight: 600, fontSize: 14, borderLeft: c > 0 && si === 0 ? SEP : undefined }}
|
||||
>
|
||||
{seriesTotal(s, c)}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Tiny row telling which series each sub-column is (desktop only — always a
|
||||
// fixed pair, sB may be absent for a trailing odd series).
|
||||
const seriesTags = (sA: number, sB: number | undefined) => (
|
||||
<div
|
||||
className="border-b border-gold/10"
|
||||
style={{ display: 'grid', gridTemplateColumns: DESKTOP_COLS, padding: '5px 8px 3px' }}
|
||||
>
|
||||
<div />
|
||||
{seats.map((_, c) => (
|
||||
<Fragment key={c}>
|
||||
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064', borderLeft: c > 0 ? SEP : undefined }}>
|
||||
{sA + 1}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064' }}>
|
||||
{sB !== undefined ? sB + 1 : ''}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Desktop: pair series side by side (S1|S2, then S3|S4) with a blank row between.
|
||||
const desktopBody = (() => {
|
||||
const blocks: number[][] = [];
|
||||
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
|
||||
return blocks.map((block, bi) => {
|
||||
const [sA, sB] = [block[0], block[1]];
|
||||
const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
|
||||
return (
|
||||
<Fragment key={bi}>
|
||||
{seriesTags(sA, sB)}
|
||||
{roundNums.map((rn) => roundRow(rn, [sA, sB]))}
|
||||
{sigmaRow([sA, sB])}
|
||||
{bi < blocks.length - 1 && <div style={{ height: 12 }} />}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile: one series per block, stacked vertically, 4 player columns each.
|
||||
const mobileBody = seriesNums.map((s, si) => {
|
||||
const roundNums = roundsOf(s).sort((a, b) => a - b);
|
||||
return (
|
||||
<Fragment key={s}>
|
||||
<div style={{ padding: '7px 8px 3px', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.09em', color: '#8a8064' }}>
|
||||
Séria {s + 1}
|
||||
</div>
|
||||
{roundNums.map((rn) => roundRow(rn, [s]))}
|
||||
{sigmaRow([s])}
|
||||
{si < seriesNums.length - 1 && <div style={{ height: 12 }} />}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
|
||||
<button onClick={onBack} className="text-sm text-green-dim hover:text-gold mb-4">
|
||||
← Späť na zoznam
|
||||
</button>
|
||||
<h1 className="font-serif text-2xl text-gold mb-1 truncate">{detail.name || 'Detail hry'}</h1>
|
||||
<p className="text-xs text-green-dim mb-4">{fmtDate(detail.created_at)}</p>
|
||||
|
||||
{/* Desktop packs 8 columns → allow horizontal scroll on narrow widths;
|
||||
mobile uses only 4 columns and fits the phone, so no scroll. */}
|
||||
<div className={`bg-header border border-[#142018] rounded-xl ${desktop ? 'overflow-x-auto' : ''}`}>
|
||||
<div style={desktop ? { minWidth: 440 } : undefined}>
|
||||
{header}
|
||||
{desktop ? desktopBody : mobileBody}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,37 +25,37 @@ export default function Lobby() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8">
|
||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||
<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
|
||||
<h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
|
||||
<button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
|
||||
Odísť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-4 mb-4 flex items-center justify-between">
|
||||
<div className="bg-header border border-[#142018] 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>
|
||||
<p className="text-xs uppercase tracking-[.1em] text-green-dim mb-1">Kód hry</p>
|
||||
<p className="font-mono text-sm text-green-score 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"
|
||||
className="ml-3 px-3 py-1 rounded-lg text-sm border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
|
||||
>
|
||||
Kopirovat
|
||||
Kopírovať
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-4 mb-6 flex flex-col gap-3">
|
||||
<div className="bg-header border border-[#142018] 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 className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
|
||||
{p ? '✦' : '○'}
|
||||
</span>
|
||||
<span className={p ? 'text-white' : 'text-gray-500 italic'}>
|
||||
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'}
|
||||
<span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
|
||||
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa…'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -65,9 +65,9 @@ export default function Lobby() {
|
||||
<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"
|
||||
className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg disabled:opacity-40 disabled:cursor-default hover:bg-gold-bright transition-colors"
|
||||
>
|
||||
{isHost ? (canStart ? 'Zacat hru' : `Caka sa na hracov (${players.length}/4)`) : 'Caka sa na hosta...'}
|
||||
{isHost ? (canStart ? 'Začať hru' : `Čaká sa na hráčov (${players.length}/4)`) : 'Čaká sa na hostiteľa…'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user