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:
tim
2026-07-01 00:11:42 +02:00
co-authored by Claude Sonnet 5
parent 30c32b7714
commit 2c2f07c2ec
28 changed files with 1472 additions and 395 deletions
+21 -19
View File
@@ -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>