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