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>
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
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 { 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);
|
||
|
||
// Hold the last completed trick visible for TRICK_LINGER_MS after it finishes.
|
||
const [lingeredStash, setLingeredStash] = useState<StashData | null>(null);
|
||
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const previousStash = gameStatus?.status.previous_stash ?? null;
|
||
|
||
useEffect(() => {
|
||
if (!previousStash) return;
|
||
setLingeredStash(previousStash);
|
||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||
lingerTimer.current = setTimeout(() => setLingeredStash(null), TRICK_LINGER_MS);
|
||
return () => {
|
||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||
};
|
||
// 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-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||
}
|
||
|
||
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
|
||
const {
|
||
active_player,
|
||
active_round_guesses,
|
||
active_round_stashes,
|
||
active_stash,
|
||
standings = [],
|
||
standings_guesses = [],
|
||
} = status;
|
||
|
||
if (completed) {
|
||
return <GameOver players={players} standings={standings} />;
|
||
}
|
||
|
||
const myOrder = myPlayer.order;
|
||
const isPlayPhase = active_stash !== undefined;
|
||
const myTurnToPlay = isPlayPhase && active_player === myOrder;
|
||
|
||
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
|
||
const displayedStash: StashData | null =
|
||
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null;
|
||
|
||
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 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;
|
||
|
||
// ── shared pieces ────────────────────────────────────────────────
|
||
const bannerText = activeOf(myOrder)
|
||
? isPlayPhase
|
||
? 'Zahraj kartu'
|
||
: 'Zadaj tip'
|
||
: `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
|
||
|
||
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>
|
||
);
|
||
|
||
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>
|
||
);
|
||
|
||
// 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
|
||
);
|
||
|
||
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>
|
||
);
|
||
|
||
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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|