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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user