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
+42 -37
View File
@@ -19,61 +19,66 @@ const VALUE_LABEL: Record<string, string> = {
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
};
// Per-size geometry. sm/md sit on the table, lg/xl are hand cards.
const DIMS = {
sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 },
md: { w: 56, h: 80, radius: 6, inset: 4, label: 12, suitSm: 10, suitLg: 30 },
lg: { w: 60, h: 84, radius: 7, inset: 5, label: 12, suitSm: 9, suitLg: 30 },
xl: { w: 72, h: 100, radius: 8, inset: 6, label: 14, suitSm: 11, suitLg: 38 },
} as const;
interface Props {
card: Card;
onClick?: () => void;
disabled?: boolean;
selected?: boolean;
size?: 'sm' | 'md' | 'lg';
/** Playable card on your turn — gold glow border + lift. */
highlight?: boolean;
size?: keyof typeof DIMS;
}
export default function CardView({ card, onClick, disabled = false, selected = false, size = 'md' }: Props) {
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
const symbol = SUIT_SYMBOL[card.color];
const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value];
const dims = {
sm: { cls: 'w-10 h-14', label: 9, iconSm: 9, iconLg: 18, inset: 2 },
md: { cls: 'w-14 h-20', label: 11, iconSm: 11, iconLg: 26, inset: 3 },
lg: { cls: 'w-20 h-28', label: 15, iconSm: 15, iconLg: 38, inset: 4 },
}[size];
const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value];
const d = DIMS[size];
const interactive = !disabled && !!onClick;
const corner = (rotated: boolean) => (
<span
className="absolute flex flex-col items-center leading-none"
style={
rotated
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
: { top: d.inset, left: d.inset }
}
>
<span style={{ color, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
</span>
);
return (
<button
onClick={onClick}
disabled={disabled}
disabled={disabled || !onClick}
style={{ width: d.w, height: d.h, borderRadius: d.radius }}
className={[
dims.cls,
'relative bg-white border rounded-md shadow-sm transition-transform overflow-hidden',
selected && !disabled ? 'border-yellow-400 -translate-y-2' : 'border-gray-300',
disabled ? 'opacity-50 cursor-default' : '',
interactive ? 'hover:-translate-y-1 cursor-pointer active:scale-95' : 'cursor-default',
'relative bg-white overflow-hidden flex-none transition-transform',
highlight
? 'border-2 border-gold animate-g1 -translate-y-2'
: 'border border-[#ddd8d0] shadow-[0_2px_8px_rgba(0,0,0,.35)]',
disabled && !highlight ? 'opacity-[.35]' : '',
interactive ? 'cursor-pointer hover:-translate-y-1 active:scale-95' : 'cursor-default',
].join(' ')}
>
<span
className="absolute pointer-events-none rounded"
style={{ inset: dims.inset, border: '0.5px solid #f0ece0' }}
/>
<span className="absolute top-1 left-1 flex flex-col items-center" style={{ gap: 1 }}>
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
</span>
{corner(false)}
<span className="absolute inset-0 flex items-center justify-center">
<span style={{ color, fontSize: dims.iconLg, lineHeight: 1 }}>{symbol}</span>
</span>
<span className="absolute bottom-1 right-1 flex flex-col items-center rotate-180" style={{ gap: 1 }}>
<span style={{ color, fontSize: dims.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
{label}
</span>
<span style={{ color, fontSize: dims.iconSm, lineHeight: 1 }}>{symbol}</span>
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
</span>
{corner(true)}
</button>
);
}
+43
View File
@@ -0,0 +1,43 @@
interface Props {
/** Exact number of cards the player still holds. */
count: number;
/** Row (top player) or column (side players). */
direction: 'row' | 'col';
desktop?: boolean;
}
/** Overlapping fan of face-down cards next to an opponent's circle — one card
* per card still in their hand, so the stack shrinks as they play. */
export default function FaceDownCards({ count, direction, desktop = false }: Props) {
const n = Math.max(0, Math.min(count, 8));
if (n === 0) return null;
const row = direction === 'row';
const w = desktop ? 40 : 25;
const h = desktop ? 56 : 36;
const overlap = row ? Math.round(w * 0.45) : Math.round(h * 0.5);
return (
<div className="flex" style={{ flexDirection: row ? 'row' : 'column' }}>
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
style={{
width: w,
height: h,
marginLeft: row && i > 0 ? -overlap : 0,
marginTop: !row && i > 0 ? -overlap : 0,
zIndex: i,
background:
i % 2 === 0
? 'linear-gradient(150deg,#1d4a28,#0e2818)'
: 'linear-gradient(150deg,#1b4424,#0d2616)',
borderRadius: 3,
border: '1px solid rgba(201,168,76,.18)',
boxShadow: '0 2px 6px rgba(0,0,0,.5)',
}}
/>
))}
</div>
);
}
+9 -8
View File
@@ -17,8 +17,9 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
if (!isMyTurn) {
return (
<p className="text-gray-400 text-sm text-center py-2">
Caka sa na tip: <span className="text-white font-semibold">{activePlayerName}</span>
<p className="text-center text-sm text-green-dim py-3">
Čaká sa na tip:{' '}
<span className="font-serif text-gold">{activePlayerName}</span>
</p>
);
}
@@ -26,8 +27,8 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
return (
<div className="flex flex-col items-center gap-2 py-2">
<p className="text-sm text-gray-300">Tvoj tip (pocet kopok):</p>
<div className="flex flex-col items-center gap-3 py-3">
<p className="font-serif italic text-gold-dim text-[14px]">Zadaj svoj tip (počet kopiek)</p>
<div className="flex flex-wrap gap-2 justify-center">
{options.map((n) => {
const isForbidden = n === forbidden;
@@ -36,12 +37,12 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
key={n}
disabled={isForbidden}
onClick={() => emit.addGuess(n)}
title={isForbidden ? 'Zakázaná hodnota (suma = počet kopok)' : undefined}
title={isForbidden ? 'Zakázaná hodnota (súčet = počet kopiek)' : undefined}
className={[
'w-10 h-10 rounded-lg font-bold text-lg border-2 transition-colors',
'w-11 h-11 rounded-full font-serif text-lg border-2 transition-colors',
isForbidden
? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed'
: 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95',
? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
: 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
].join(' ')}
>
{n}
+71 -18
View File
@@ -21,30 +21,83 @@ interface Props {
myTurn: boolean;
isPlayPhase: boolean;
playableKeys?: Set<string>;
desktop?: boolean;
}
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys }: Props) {
export default function Hand({ hand, myTurn, isPlayPhase, playableKeys, desktop = false }: Props) {
const groups = groupedByColor(hand);
if (groups.length === 0) return null;
const canPlay = isPlayPhase && myTurn;
const cardProps = (key: string) => {
const legal = playableKeys === undefined || playableKeys.has(key);
const playable = canPlay && legal;
// Cards stay light by default; darken only the illegal ones, and only while
// it's actually your turn to play.
const dimmed = canPlay && !legal;
return {
card: hand[key],
highlight: playable,
disabled: dimmed,
onClick: playable ? () => emit.playCard(key) : undefined,
};
};
return (
<div className="flex flex-wrap gap-3 justify-center py-2">
{groups.map(({ color, keys }) => (
<div key={color} className="flex gap-1">
{keys.map((key) => {
// During guessing phase cards are visible at full opacity (just not clickable).
// Only dim cards during the play phase when they can't be played.
const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key)));
return (
<CardView
key={key}
card={hand[key]}
disabled={disabled}
onClick={() => emit.playCard(key)}
/>
);
})}
<div className="bg-header border-t border-[#111a13] px-4 pt-3 pb-7">
<div className="flex items-center justify-center gap-2 mb-3">
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-r from-transparent to-gold/20" />
<span className="uppercase tracking-[.13em] text-[9px] text-green-dim">Tvoje karty</span>
<div className="h-px flex-1 max-w-[80px] bg-gradient-to-l from-transparent to-gold/20" />
</div>
{desktop ? (
// Desktop has room — keep cards grouped by suit, wrap if needed.
<div className="flex flex-wrap gap-3 justify-center items-end">
{groups.map(({ color, keys }) => (
<div key={color} className="flex gap-1 items-end">
{keys.map((key) => (
<CardView key={key} size="xl" {...cardProps(key)} />
))}
</div>
))}
</div>
) : (
<MobileHand groups={groups} cardProps={cardProps} />
)}
</div>
);
}
/** All cards in a single overlapping row that always fits the mobile width:
* small gap when few cards, partial overlap when many. */
function MobileHand({
groups,
cardProps,
}: {
groups: { color: CardColor; keys: string[] }[];
cardProps: (key: string) => { card: Hand[string]; highlight: boolean; disabled: boolean; onClick?: () => void };
}) {
const keys = groups.flatMap((g) => g.keys);
const n = keys.length;
const CARD_W = 60; // matches CardView size "lg"
const MAX_ROW = 300; // keep within a small phone's usable width (~360px screens)
// Horizontal step between successive cards; <CARD_W means they overlap.
const step = n > 1 ? Math.min(CARD_W + 6, (MAX_ROW - CARD_W) / (n - 1)) : 0;
const margin = step - CARD_W; // negative → overlap, positive → gap
return (
<div className="flex justify-center items-end">
{keys.map((key, i) => (
<div
key={key}
className="relative"
// Playable cards lift up — keep them above their neighbours.
style={{ marginLeft: i === 0 ? 0 : margin, zIndex: cardProps(key).highlight ? 100 + i : i }}
>
<CardView size="lg" {...cardProps(key)} />
</div>
))}
</div>
+9 -9
View File
@@ -26,9 +26,9 @@ export default function NameModal({ onClose }: Props) {
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4">
<div className="bg-slate-800 rounded-2xl p-6 w-full max-w-sm shadow-xl">
<h2 className="text-lg font-bold mb-4 text-white">Nazov hry</h2>
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 p-4">
<div className="bg-header border border-[#142018] rounded-2xl p-6 w-full max-w-sm shadow-[0_28px_88px_rgba(0,0,0,.65)]">
<h2 className="font-serif text-xl mb-4 text-gold">Názov hry</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
autoFocus
@@ -36,23 +36,23 @@ export default function NameModal({ onClose }: Props) {
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
placeholder="Napr. Vecerna partia"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Napr. Večerná partia"
className="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"
/>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-gray-400 hover:text-white"
className="px-4 py-2 rounded-lg text-green-dim hover:text-gold"
>
Zrusit
Zrušiť
</button>
<button
type="submit"
disabled={!name.trim()}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold disabled:opacity-40"
className="px-4 py-2 rounded-lg bg-gold text-table font-serif font-semibold disabled:opacity-40 hover:bg-gold-bright transition-colors"
>
Vytvorit
Vytvoriť
</button>
</div>
</form>
+56
View File
@@ -0,0 +1,56 @@
interface Props {
name: string;
/** Tricks won this round. */
won: number;
/** Bid for this round (null until the player has guessed). */
guess: number | null;
/** Whether it is this player's turn — the only state that highlights a circle. */
active: boolean;
size?: number;
}
export default function PlayerCircle({ name, won, guess, active, size = 52 }: Props) {
const nameFont = Math.max(9, Math.round(size * 0.17));
const valueFont = Math.round(size * 0.32);
// Oval: width = size, height a touch shorter so it reads as an ellipse.
const height = Math.round(size * 0.78);
return (
<div
// Only the active player is highlighted (gold ring + glow) — colors come
// from the velvet-table palette tokens (tailwind.config.js), not literals.
className={`flex flex-col items-center justify-center rounded-[50%] ${
active ? 'bg-circle-active border-2 border-gold' : 'bg-circle border-[1.5px] border-gold/20'
}`}
style={{
width: size,
height,
boxShadow: '0 2px 10px rgba(0,0,0,.45)',
animation: active ? 'ar 2.2s ease-in-out infinite' : undefined,
}}
>
<span
className={`uppercase leading-tight text-center ${active ? 'text-gold' : 'text-green-circle'}`}
style={{
fontFamily: '"DM Sans",sans-serif',
fontSize: nameFont,
letterSpacing: '.09em',
fontWeight: 500,
}}
>
{name}
</span>
<span
className={`leading-none ${active ? 'text-gold-bright' : 'text-gold'}`}
style={{
fontFamily: '"Playfair Display",serif',
fontSize: valueFont,
fontWeight: active ? 700 : 400,
}}
>
{won}
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
</span>
</div>
);
}
+8 -8
View File
@@ -9,17 +9,17 @@ export default function RulesModal({ onClose }: Props) {
onClick={onClose}
>
<div
className="relative bg-slate-900 rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed"
className="relative bg-header border border-[#142018] rounded-2xl w-full max-w-lg my-6 p-6 text-sm leading-relaxed text-green-score shadow-[0_28px_88px_rgba(0,0,0,.65)]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white text-xl leading-none"
className="absolute top-4 right-4 text-green-dim hover:text-gold text-xl leading-none"
>
</button>
<h1 className="text-xl font-bold mb-4">Pravidlá hry Bridžik</h1>
<h1 className="font-serif text-2xl text-gold mb-4">Pravidlá hry Bridžik</h1>
<Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p>
@@ -37,7 +37,7 @@ export default function RulesModal({ onClose }: Props) {
<Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p>
<p className="mt-1">Každý hráč tipuje, koľko kopiek v kole získa (0 počet kopiek).</p>
<p className="mt-1 text-yellow-300">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="mt-1 text-gold-dim">Pravidlo bridžika: súčet tipov nesmie presne rovnať počtu kopiek v kole posledný tipujúci nemôže zadať tip, ktorý by toto spôsobil.</p>
<p className="font-semibold mt-3">2. Hranie kariet</p>
<p className="mt-1">Prvú kopku otvára hráč s <b>najvyšším tipom</b>. Každú ďalšiu otvára víťaz predchádzajúcej kopky.</p>
@@ -58,13 +58,13 @@ export default function RulesModal({ onClose }: Props) {
<Section title="Bodovanie">
<p>Po každom kole: ak sa tip <b>presne zhoduje</b> s počtom získaných kopiek <b>10 + tip</b> bodov, inak <b>0</b>.</p>
<p className="mt-1 text-gray-400">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-1 text-green-dim">Príklad: tipoval 3, získal 3 13 bodov. Tipoval 3, získal 2 0 bodov.</p>
<p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section>
<button
onClick={onClose}
className="mt-4 w-full py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 font-semibold"
className="mt-4 w-full py-2.5 rounded-xl border border-gold/30 text-gold hover:bg-gold hover:text-table font-serif font-semibold transition-colors"
>
Zavrieť
</button>
@@ -76,8 +76,8 @@ export default function RulesModal({ onClose }: Props) {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h2 className="font-bold text-base text-green-400 mb-1">{title}</h2>
<div className="text-gray-200">{children}</div>
<h2 className="font-serif text-base text-gold mb-1">{title}</h2>
<div className="text-green-score">{children}</div>
</div>
);
}
+175 -36
View File
@@ -4,53 +4,192 @@ import { computeTotal } from '../lib/standings';
interface Props {
standings: number[][][];
/** Tips per series/round/seat, same shape as standings. */
guesses?: number[][][];
players: PlayerInfo[];
myOrder: number;
/** Desktop renders an always-open sidebar; mobile a collapsible panel. */
desktop?: boolean;
}
export default function Standings({ standings, players }: Props) {
export default function Standings({ standings, guesses = [], players, myOrder, desktop = false }: Props) {
const [open, setOpen] = useState(false);
const sorted = [...players]
.map((p) => ({ ...p, total: computeTotal(standings, p.order) }))
.sort((a, b) => b.total - a.total);
// Player columns in seat order; the local player's column is highlighted.
const cols = [...players].sort((a, b) => a.order - b.order);
// Completed-round count before each series (running total), so each series'
// round index can be offset in a single pass instead of re-summing per row.
const seriesRoundOffsets: number[] = [];
let completedRounds = 0;
for (const s of standings) {
seriesRoundOffsets.push(completedRounds);
completedRounds += s.length;
}
// Engine: every series is exactly 8 rounds → a series with 8 entries is done,
// and gets a per-series summary row after its last round.
const ROUNDS_PER_SERIES = 8;
// Bigger, more legible type on the wide desktop sidebar; compact on mobile.
const fz = {
head: desktop ? 11 : 10,
idx: desktop ? 13 : 9,
cell: desktop ? 19 : 14,
dot: desktop ? 18 : 13,
sigma: desktop ? 13 : 9,
total: desktop ? 20 : 18,
};
const table = (
<div className="flex-1 flex flex-col px-3 pt-3 pb-4">
{/* Column headers */}
<div
className="grid items-end mb-1"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div />
{cols.map((p) => (
<div
key={p.order}
className={`text-center uppercase tracking-[.09em] truncate ${
p.order === myOrder ? 'text-gold' : 'text-green-dim'
}`}
style={{ fontSize: fz.head }}
>
{p.name}
</div>
))}
</div>
<div className="h-px bg-gold/10 mb-1" />
{/* Completed rounds, grouped by series with a per-series summary row */}
{standings.flatMap((seriesRounds, si) => {
const priorRounds = seriesRoundOffsets[si];
const elems = seriesRounds.map((scores, lri) => (
<div
key={`r-${si}-${lri}`}
className="grid items-center py-1 border-b border-gold/[.05]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center text-[#7a7252]" style={{ fontSize: fz.idx }}>
{priorRounds + lri + 1}
</div>
{cols.map((p) => {
const points = scores[p.order] ?? 0;
// Failed tip (0 points) → show the struck-through tip instead of 0.
if (points === 0) {
return (
<div
key={p.order}
className="text-center font-serif leading-none line-through"
style={{ fontSize: fz.cell, color: '#7a6e4a' }}
>
{guesses[si]?.[lri]?.[p.order] ?? 0}
</div>
);
}
return (
<div
key={p.order}
className="text-center font-serif leading-none"
style={{ fontSize: fz.cell, color: p.order === myOrder ? '#f0dca8' : '#c8bb95' }}
>
{points}
</div>
);
})}
</div>
));
// After a finished series, sum its points per player.
if (seriesRounds.length === ROUNDS_PER_SERIES) {
elems.push(
<div
key={`s-${si}`}
className="grid items-center py-1 my-0.5 rounded bg-gold/[.07]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center font-serif text-gold" style={{ fontSize: fz.sigma }}>
Σ{si + 1}
</div>
{cols.map((p) => {
const sum = seriesRounds.reduce((a, r) => a + (r[p.order] ?? 0), 0);
return (
<div
key={p.order}
className={`text-center font-serif leading-none ${
p.order === myOrder ? 'text-gold-dim' : 'text-green-score'
}`}
style={{ fontSize: fz.cell, fontWeight: 600 }}
>
{sum}
</div>
);
})}
</div>,
);
}
return elems;
})}
{/* Active round placeholder */}
<div
className="grid items-center py-1 rounded mt-0.5 bg-gold/[.04]"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center font-medium text-gold" style={{ fontSize: fz.idx }}>{completedRounds + 1}</div>
{cols.map((p) => (
<div key={p.order} className="text-center text-[#7a7252]" style={{ fontSize: fz.dot }}>·</div>
))}
</div>
<div className="flex-1 min-h-2" />
<div className="h-px bg-gold/20 mb-2" />
{/* Totals */}
<div
className="grid items-center py-0.5"
style={{ gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` }}
>
<div className="text-center uppercase tracking-[.08em] text-green-dim" style={{ fontSize: fz.sigma }}>
Σ
</div>
{cols.map((p) => (
<div
key={p.order}
className={`text-center font-serif leading-none ${
p.order === myOrder ? 'text-gold-dim' : 'text-[#c8bb95]'
}`}
style={{ fontSize: fz.total, fontWeight: p.order === myOrder ? 700 : 600 }}
>
{computeTotal(standings, p.order)}
</div>
))}
</div>
</div>
);
if (desktop) {
return (
<aside className="w-[268px] flex-shrink-0 bg-header border-l border-[#142018] flex flex-col">
<div className="h-[58px] flex items-center gap-2 px-5 border-b border-[#14221a]">
<span className="font-serif uppercase tracking-[.12em] text-[13px] text-gold">Skóre</span>
</div>
{table}
</aside>
);
}
// Mobile: collapsible panel
return (
<div className="bg-slate-800 rounded-xl overflow-hidden">
<div className="bg-header/80 border border-[#142018] rounded-xl overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex justify-between items-center px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-slate-700"
className="w-full flex justify-between items-center px-4 py-2 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
>
<span>Skore</span>
<span>{open ? '▲' : '▼'}</span>
<span>Skóre</span>
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
</button>
{open && (
<table className="w-full text-sm text-center">
<thead>
<tr className="text-gray-400 border-b border-slate-700">
<th className="py-1 px-2 text-left">Hrac</th>
{standings.map((_, si) => (
<th key={si} className="py-1 px-2">S{si + 1}</th>
))}
<th className="py-1 px-2">Spolu</th>
</tr>
</thead>
<tbody>
{sorted.map((p) => (
<tr key={p.order} className="border-b border-slate-700/50">
<td className="py-1 px-2 text-left text-gray-200">{p.name}</td>
{standings.map((series, si) => {
return (
<td key={si} className="py-1 px-2 text-gray-300">
{computeTotal([series], p.order)}
</td>
);
})}
<td className="py-1 px-2 font-bold text-white">{p.total}</td>
</tr>
))}
</tbody>
</table>
)}
{open && table}
</div>
);
}
+54 -14
View File
@@ -1,26 +1,66 @@
import type { StashData } from '../types';
import type { PlayerInfo, StashData } from '../types';
import CardView from './CardView';
interface Props {
stash: StashData | null;
players: PlayerInfo[];
myOrder: number;
}
export default function Trick({ stash }: Props) {
const cards = stash
? [0, 1, 2, 3]
.map((i) => (stash.first_player + i) % 4)
.map((order) => stash.cards[String(order)])
.filter(Boolean)
const ROTATIONS = [-3, 2, -1, 1];
// Entry direction by seat offset from me: 0=me(bottom) 1=left 2=top 3=right.
const FLY_BY_OFFSET = ['fly-bottom', 'fly-left', 'fly-top', 'fly-right'];
export default function Trick({ stash, players, myOrder }: Props) {
// Seat order, starting from whoever led the trick.
const playOrder = stash
? [0, 1, 2, 3].map((i) => (stash.first_player + i) % 4)
: [];
const nameFor = (order: number) =>
players.find((p) => p.order === order)?.name ?? '';
const overlap = -16;
const slotH = 80;
if (!stash) {
return <div className="flex items-center justify-center" style={{ minHeight: slotH + 14 }} />;
}
return (
<div className="bg-green-900/60 rounded-xl p-4">
<p className="text-xs text-green-300 mb-3 text-center">Aktualny stich</p>
<div className="flex gap-3 justify-center min-h-28">
{cards.map((card, i) => (
<CardView key={i} card={card} size="lg" />
))}
</div>
<div className="flex items-center justify-center">
{playOrder.map((order, i) => {
const card = stash.cards[String(order)];
// Only render cards that have actually been played — no placeholder slot
// for players still to play this trick.
if (!card) return null;
const offset = (order - myOrder + 4) % 4;
return (
<div
key={order}
className="relative flex flex-col items-center"
style={{ marginLeft: i === 0 ? 0 : overlap, zIndex: i + 1 }}
>
<span
className="uppercase text-center"
style={{
fontSize: 8,
letterSpacing: '.05em',
marginBottom: 3,
color: 'rgba(216,203,166,.72)',
}}
>
{nameFor(order)}
</span>
{/* Outer: flies in from the player's direction. Inner: static rotation. */}
<div style={{ animation: `${FLY_BY_OFFSET[offset]} .42s cubic-bezier(.2,.7,.3,1) both` }}>
<div style={{ transform: `rotate(${ROTATIONS[i]}deg)` }}>
<CardView card={card} size="md" />
</div>
</div>
</div>
);
})}
</div>
);
}