Files
bridzik/frontend/src/components/Hand.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

106 lines
3.6 KiB
TypeScript

import type { CardColor, Hand } from '../types';
import CardView from './CardView';
import { emit } from '../lib/socket';
const COLOR_ORDER: CardColor[] = ['HEARTS', 'LEAVES', 'ACORNS', 'BELLS'];
const VALUE_ORDER = ['C7', 'C8', 'C9', 'C10', 'LOWER', 'UPPER', 'KING', 'ACE'];
function groupedByColor(hand: Hand): { color: CardColor; keys: string[] }[] {
return COLOR_ORDER
.map((color) => ({
color,
keys: Object.keys(hand)
.filter((k) => hand[k].color === color)
.sort((a, b) => VALUE_ORDER.indexOf(hand[a].value) - VALUE_ORDER.indexOf(hand[b].value)),
}))
.filter((g) => g.keys.length > 0);
}
interface Props {
hand: Hand;
myTurn: boolean;
isPlayPhase: boolean;
playableKeys?: Set<string>;
desktop?: boolean;
}
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="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>
);
}