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>
106 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|