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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user