Files
bridzik/frontend/src/components/Trick.tsx
T
timandClaude Sonnet 5 2c2f07c2ec 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>
2026-07-01 00:11:42 +02:00

67 lines
2.1 KiB
TypeScript

import type { PlayerInfo, StashData } from '../types';
import CardView from './CardView';
interface Props {
stash: StashData | null;
players: PlayerInfo[];
myOrder: number;
}
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="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>
);
}