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
parent 30c32b7714
commit 2c2f07c2ec
28 changed files with 1472 additions and 395 deletions
+37 -7
View File
@@ -146,7 +146,7 @@ async def send_game_status(gid: str):
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder)) status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
# Use DB-backed standings so the score is correct even after a server restart # Use DB-backed standings so the score is correct even after a server restart
# (the engine only knows rounds completed since restart). # (the engine only knows rounds completed since restart).
status["standings"] = await history.get_standings(gid) status["standings"], status["standings_guesses"] = await history.get_standings(gid)
await sio.emit( await sio.emit(
"game_status", "game_status",
{ {
@@ -180,9 +180,11 @@ async def send_error(sid: str, message: str):
async def _mark_player_offline(game: "Game", player: "Player"): async def _mark_player_offline(game: "Game", player: "Player"):
"""Mark player disconnected, delete the game if everyone left, else notify the room.""" """Mark player disconnected. An unstarted game with nobody left is cleaned
up; a started game is kept in memory so it stays in the lobby and can be
resumed (it's torn down only by end_game)."""
player.connected = False player.connected = False
if not any(p.connected for p in game.players): if not any(p.connected for p in game.players) and not game.started:
del games[game.gid] del games[game.gid]
else: else:
await sio.emit( await sio.emit(
@@ -212,6 +214,12 @@ async def _restore_unfinished_games():
for info in await history.get_unfinished_games(): for info in await history.get_unfinished_games():
if info["gid"] in games: if info["gid"] in games:
continue continue
_load_game_into_memory(info)
def _load_game_into_memory(info: dict) -> "Game":
"""Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline,
a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie."""
game = Game(info["gid"], info["name"]) game = Game(info["gid"], info["name"])
game.bridzik_core = info["core"] game.bridzik_core = info["core"]
game.started = True game.started = True
@@ -220,6 +228,7 @@ async def _restore_unfinished_games():
player.connected = False player.connected = False
game.players.append(player) game.players.append(player)
games[info["gid"]] = game games[info["gid"]] = game
return game
# --- connection lifecycle ------------------------------------------------- # --- connection lifecycle -------------------------------------------------
@@ -382,13 +391,13 @@ async def start_game(sid, gid):
@sio.on("end_game") @sio.on("end_game")
async def end_game(sid, gid): async def end_game(sid, gid):
"""Host (seat 0) permanently ends a game that won't be finished. Marks it """Any seated player can permanently end a game that won't be finished --
ended in the DB (so it won't be restored) and sends everyone back to the lobby.""" not just the host, so the other players aren't stuck forever if the host
abandons the game. Marks it ended in the DB (so it won't be restored) and
sends everyone back to the lobby."""
sess = sessions.get(sid) sess = sessions.get(sid)
if sess is None or sess["gid"] != gid: if sess is None or sess["gid"] != gid:
return await send_error(sid, "Nie ste v tejto hre.") return await send_error(sid, "Nie ste v tejto hre.")
if sess["order"] != 0:
return await send_error(sid, "Iba hostitel moze ukoncit hru.")
game = games.get(gid) game = games.get(gid)
if game is None: if game is None:
return await send_error(sid, "Hra neexistuje.") return await send_error(sid, "Hra neexistuje.")
@@ -471,6 +480,27 @@ async def rejoin_game(sid, gid):
await broadcast_lobby() await broadcast_lobby()
@sio.on("restore_game")
async def restore_game(sid, gid):
"""Obnov predcasne ukoncenu hru z historie spat do lobby. Smie ju vyvolat
iba hrac danej hry; v lobby sa potom objavi ako rozohrata a clenovia sa
pripoja cez `rejoin_game`."""
account = accounts.get(sid)
if account is None:
return await send_error(sid, "Musíte byť prihlásený.")
if gid in games:
# Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta.
await sio.emit("game_restored", {"gid": gid}, to=sid)
return await sio.emit("get_games", {"games": public_games()}, to=sid)
info = await history.reopen_game(gid, account["player_id"])
if info is None:
return await send_error(sid, "Hru sa nepodarilo obnovit.")
_load_game_into_memory(info)
await sio.emit("game_restored", {"gid": gid}, to=sid)
await broadcast_lobby()
# --- in-game actions (seat derived from the connection, never the client) - # --- in-game actions (seat derived from the connection, never the client) -
@sio.on("game_status") @sio.on("game_status")
+98 -33
View File
@@ -9,10 +9,20 @@ from random import shuffle
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from bridzik import Bridzik, Round, Series from bridzik import Bridzik, ROUNDS_PER_SERIES, Round, SERIES_PER_GAME, Series
from db.db import async_session from db.db import async_session
from db.models import Game, Guess, Player from db.models import Game, Guess, Player
# Naplno dohrana hra ma zapisanych SERIES_PER_GAME * ROUNDS_PER_SERIES
# dokoncenych kol -- tvar hry je definovany v bridzik.py, tu sa len cita.
FULL_GAME_ROUNDS = SERIES_PER_GAME * ROUNDS_PER_SERIES
def _utcnow_naive() -> datetime:
"""Naive UTC `datetime` na zapis do `ended_at`. Stlpec je TIMESTAMP WITHOUT
TIME ZONE (ako `created_at`), tz-aware hodnotu by asyncpg/Postgres odmietol."""
return datetime.now(timezone.utc).replace(tzinfo=None)
async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None: async def record_game_started(gid: str, name: str, player_ids: list[int]) -> None:
"""Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne.""" """Zapise riadok Game so 4 ID hracov (podla sedadla). Idempotentne."""
@@ -75,19 +85,21 @@ async def record_completed_rounds(gid: str, core) -> None:
game.round = last_series.get_last_round().round_number game.round = last_series.get_last_round().round_number
if core.is_completed() and game.ended_at is None: if core.is_completed() and game.ended_at is None:
game.ended_at = datetime.now(timezone.utc) game.ended_at = _utcnow_naive()
await session.commit() await session.commit()
async def get_standings(gid: str) -> list[list[list[int]]]: async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
"""Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo] """Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
= [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte. `[serie][kolo][sedadlo 0..3]`. Vracia dvojicu `(points, guesses)` -- tipy
su tam, aby frontend pri 0 bodoch ukazal preskrtnuty tip namiesto nuly.
Citaju sa z tych istych `Guess` riadkov, takze jeden dotaz staci.
""" """
async with async_session() as session: async with async_session() as session:
game = await session.get(Game, gid) game = await session.get(Game, gid)
if game is None: if game is None:
return [] return [], []
seat_of = { seat_of = {
game.player0_id: 0, game.player0_id: 0,
game.player1_id: 1, game.player1_id: 1,
@@ -102,18 +114,23 @@ async def get_standings(gid: str) -> list[list[list[int]]]:
) )
).all() ).all()
series_map: dict[int, dict[int, list[int]]] = {} # series_map[serie][kolo] = ([body sedadiel], [tipy sedadiel])
series_map: dict[int, dict[int, tuple[list[int], list[int]]]] = {}
for gz in rows: for gz in rows:
rounds = series_map.setdefault(gz.series_number, {}) rounds = series_map.setdefault(gz.series_number, {})
points = rounds.setdefault(gz.round_number, [0, 0, 0, 0]) points, tips = rounds.setdefault(gz.round_number, ([0, 0, 0, 0], [0, 0, 0, 0]))
seat = seat_of.get(gz.player_id) seat = seat_of.get(gz.player_id)
if seat is not None: if seat is not None:
points[seat] = gz.points points[seat] = gz.points
tips[seat] = gz.guess
return [ points_table: list[list[list[int]]] = []
[series_map[s][r] for r in sorted(series_map[s])] guesses_table: list[list[list[int]]] = []
for s in sorted(series_map) for s in sorted(series_map):
] round_nums = sorted(series_map[s])
points_table.append([series_map[s][r][0] for r in round_nums])
guesses_table.append([series_map[s][r][1] for r in round_nums])
return points_table, guesses_table
async def get_player_history(player_id: int) -> list[dict]: async def get_player_history(player_id: int) -> list[dict]:
@@ -122,17 +139,24 @@ async def get_player_history(player_id: int) -> list[dict]:
stmt = ( stmt = (
select(Game) select(Game)
.where( .where(
Game.ended_at.is_not(None), # iba ukoncene hry
or_( or_(
Game.player0_id == player_id, Game.player0_id == player_id,
Game.player1_id == player_id, Game.player1_id == player_id,
Game.player2_id == player_id, Game.player2_id == player_id,
Game.player3_id == player_id, Game.player3_id == player_id,
) ),
) )
.order_by(Game.created_at.desc()) .order_by(Game.created_at.desc())
) )
games = (await session.scalars(stmt)).all() games = (await session.scalars(stmt)).all()
# Pocet dokoncenych kol na hru (na rozlisenie naplno dohranej hry od
# predcasne ukoncenej) -- jeden batch dotaz pre vsetky hry hraca.
completed_rounds = await _completed_rounds_per_game(
session, [g.id for g in games]
)
result = [] result = []
for g in games: for g in games:
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id] seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
@@ -145,10 +169,13 @@ async def get_player_history(player_id: int) -> list[dict]:
result.append( result.append(
{ {
"gid": g.id, "gid": g.id,
"name": g.name,
"created_at": g.created_at.isoformat() if g.created_at else None, "created_at": g.created_at.isoformat() if g.created_at else None,
"ended_at": g.ended_at.isoformat() if g.ended_at else None, "ended_at": g.ended_at.isoformat() if g.ended_at else None,
"players": [usernames[pid] for pid in seat_ids], "players": [usernames[pid] for pid in seat_ids],
"my_points": int(total or 0), "my_points": int(total or 0),
# True = dohrana naplno; False = predcasne ukoncena (da sa obnovit).
"completed": completed_rounds.get(g.id, 0) >= FULL_GAME_ROUNDS,
} }
) )
return result return result
@@ -173,6 +200,7 @@ async def get_game_detail(gid: str) -> dict | None:
return { return {
"gid": game.id, "gid": game.id,
"name": game.name,
"created_at": game.created_at.isoformat() if game.created_at else None, "created_at": game.created_at.isoformat() if game.created_at else None,
"ended_at": game.ended_at.isoformat() if game.ended_at else None, "ended_at": game.ended_at.isoformat() if game.ended_at else None,
"players": [ "players": [
@@ -200,6 +228,60 @@ async def _usernames_for(session, player_ids: list[int]) -> dict[int, str]:
return {p.id: p.username for p in rows} return {p.id: p.username for p in rows}
async def _completed_rounds_per_game(session, gids: list[str]) -> dict[str, int]:
"""Pocet dokoncenych (series, round) kol na hru. Guess sa zapisuje len za
dohrate kola, takze pocet unikatnych dvojic = pocet dokoncenych kol."""
if not gids:
return {}
rows = await session.execute(
select(Guess.game_id, Guess.series_number, Guess.round_number)
.where(Guess.game_id.in_(gids))
.distinct()
)
counts: dict[str, int] = {}
for r in rows:
counts[r.game_id] = counts.get(r.game_id, 0) + 1
return counts
async def _restore_info(session, game: Game) -> dict:
"""Postavi restore-payload pre jednu hru: gid, name, sedadla (player_id +
username podla poradia 0..3) a uz postaveny Bridzik na ulozenej pozicii.
Spolocny tvar pre `reopen_game` aj `get_unfinished_games`."""
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
usernames = await _usernames_for(session, seat_ids)
return {
"gid": game.id,
"name": game.name,
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
"core": rebuild_core(game.series, game.round),
}
async def reopen_game(gid: str, player_id: int) -> dict | None:
"""Znovu otvori predcasne ukoncenu hru: vymaze `ended_at` a vrati info na
obnovu do pamate (rovnaky tvar ako polozka z `get_unfinished_games`).
Vrati None, ak hra neexistuje, hrac v nej nie je, alebo uz bola dohrana
naplno (vtedy nie je co pokracovat).
"""
async with async_session() as session:
game = await session.get(Game, gid)
if game is None:
return None
seat_ids = [game.player0_id, game.player1_id, game.player2_id, game.player3_id]
if player_id not in seat_ids:
return None
counts = await _completed_rounds_per_game(session, [gid])
if counts.get(gid, 0) >= FULL_GAME_ROUNDS:
return None # naplno dohrana hra sa neobnovuje
game.ended_at = None
info = await _restore_info(session, game)
await session.commit()
return info
# --- restore --------------------------------------------------------------- # --- restore ---------------------------------------------------------------
def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik: def rebuild_core(series_number: int, round_number: int, shuffler=shuffle) -> Bridzik:
@@ -236,31 +318,14 @@ async def mark_game_ended(gid: str) -> None:
async with async_session() as session: async with async_session() as session:
game = await session.get(Game, gid) game = await session.get(Game, gid)
if game is not None and game.ended_at is None: if game is not None and game.ended_at is None:
game.ended_at = datetime.now(timezone.utc) game.ended_at = _utcnow_naive()
await session.commit() await session.commit()
async def get_unfinished_games() -> list[dict]: async def get_unfinished_games() -> list[dict]:
"""Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte. """Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte."""
Vracia per hru: gid, name, sedadla (player_id + username podla poradia 0..3)
a uz postaveny Bridzik na ulozenej pozicii.
"""
async with async_session() as session: async with async_session() as session:
games = ( games = (
await session.scalars(select(Game).where(Game.ended_at.is_(None))) await session.scalars(select(Game).where(Game.ended_at.is_(None)))
).all() ).all()
return [await _restore_info(session, g) for g in games]
result = []
for g in games:
seat_ids = [g.player0_id, g.player1_id, g.player2_id, g.player3_id]
usernames = await _usernames_for(session, seat_ids)
result.append(
{
"gid": g.id,
"name": g.name,
"seats": [(pid, usernames.get(pid, "?")) for pid in seat_ids],
"core": rebuild_core(g.series, g.round),
}
)
return result
+10 -3
View File
@@ -82,6 +82,13 @@ class Card():
cards = [Card(color, value) for value in Card_values for color in Card_colors] cards = [Card(color, value) for value in Card_values for color in Card_colors]
# Sturktura hry: kazda hra ma SERIES_PER_GAME serii, kazda seria ma
# ROUNDS_PER_SERIES kol. Jediny zdroj pravdy pre tieto cisla -- ina vrstva
# (napr. api/history.py) ich odvodzuje odtialto, nikdy si ich nevymysla sama.
SERIES_PER_GAME = 4
ROUNDS_PER_SERIES = 8
class Bridzik(): class Bridzik():
def __init__(self, shuffler=shuffle): def __init__(self, shuffler=shuffle):
self.shuffler = shuffler self.shuffler = shuffler
@@ -124,7 +131,7 @@ class Bridzik():
return status return status
def is_completed(self): def is_completed(self):
return len(self.series) == 4 and self.series[-1].is_completed() return len(self.series) == SERIES_PER_GAME and self.series[-1].is_completed()
def get_previous_stash(self): def get_previous_stash(self):
if len(self.series[-1].get_last_round().stashes) > 1: if len(self.series[-1].get_last_round().stashes) > 1:
@@ -169,7 +176,7 @@ class Series():
self.start_new_round() self.start_new_round()
def is_completed(self): def is_completed(self):
return len(self.rounds) == 8 and self.get_last_round().is_completed() return len(self.rounds) == ROUNDS_PER_SERIES and self.get_last_round().is_completed()
def get_standings(self): def get_standings(self):
return [r.get_points_summary() for r in self.rounds if r.is_completed()] return [r.get_points_summary() for r in self.rounds if r.is_completed()]
@@ -191,7 +198,7 @@ class Series():
class Round(): class Round():
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle): def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
# vyrob kopku pre toto kolo a priprav prazdne objekty # vyrob kopku pre toto kolo a priprav prazdne objekty
if round_number not in [i for i in range(8)]: if round_number not in range(ROUNDS_PER_SERIES):
raise BridzikException('Neplatne cislo kola.') raise BridzikException('Neplatne cislo kola.')
if first_player not in [0, 1, 2, 3]: if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.') raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
db: db:
image: postgres:16-alpine image: postgres:18-alpine
environment: environment:
POSTGRES_USER: bridzik POSTGRES_USER: bridzik
POSTGRES_PASSWORD: bridzik POSTGRES_PASSWORD: bridzik
@@ -8,7 +8,7 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"] test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"]
interval: 5s interval: 5s
+8 -1
View File
@@ -3,7 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bridzik</title> <meta name="theme-color" content="#090e0b" />
<title>Bridžik</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+40 -35
View File
@@ -19,61 +19,66 @@ const VALUE_LABEL: Record<string, string> = {
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A', 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 { interface Props {
card: Card; card: Card;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
selected?: boolean; /** Playable card on your turn — gold glow border + lift. */
size?: 'sm' | 'md' | 'lg'; 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 symbol = SUIT_SYMBOL[card.color];
const color = SUIT_COLOR[card.color]; const color = SUIT_COLOR[card.color];
const label = VALUE_LABEL[card.value]; const label = VALUE_LABEL[card.value];
const d = DIMS[size];
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 interactive = !disabled && !!onClick; 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 ( return (
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled || !onClick}
style={{ width: d.w, height: d.h, borderRadius: d.radius }}
className={[ className={[
dims.cls, 'relative bg-white overflow-hidden flex-none transition-transform',
'relative bg-white border rounded-md shadow-sm transition-transform overflow-hidden', highlight
selected && !disabled ? 'border-yellow-400 -translate-y-2' : 'border-gray-300', ? 'border-2 border-gold animate-g1 -translate-y-2'
disabled ? 'opacity-50 cursor-default' : '', : 'border border-[#ddd8d0] shadow-[0_2px_8px_rgba(0,0,0,.35)]',
interactive ? 'hover:-translate-y-1 cursor-pointer active:scale-95' : 'cursor-default', disabled && !highlight ? 'opacity-[.35]' : '',
interactive ? 'cursor-pointer hover:-translate-y-1 active:scale-95' : 'cursor-default',
].join(' ')} ].join(' ')}
> >
<span {corner(false)}
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>
<span className="absolute inset-0 flex items-center justify-center"> <span className="absolute inset-0 flex items-center justify-center">
<span style={{ color, fontSize: dims.iconLg, lineHeight: 1 }}>{symbol}</span> <span style={{ color, fontSize: d.suitLg, 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> </span>
{corner(true)}
</button> </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) { if (!isMyTurn) {
return ( return (
<p className="text-gray-400 text-sm text-center py-2"> <p className="text-center text-sm text-green-dim py-3">
Caka sa na tip: <span className="text-white font-semibold">{activePlayerName}</span> Čaká sa na tip:{' '}
<span className="font-serif text-gold">{activePlayerName}</span>
</p> </p>
); );
} }
@@ -26,8 +27,8 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i); const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
return ( return (
<div className="flex flex-col items-center gap-2 py-2"> <div className="flex flex-col items-center gap-3 py-3">
<p className="text-sm text-gray-300">Tvoj tip (pocet kopok):</p> <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"> <div className="flex flex-wrap gap-2 justify-center">
{options.map((n) => { {options.map((n) => {
const isForbidden = n === forbidden; const isForbidden = n === forbidden;
@@ -36,12 +37,12 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
key={n} key={n}
disabled={isForbidden} disabled={isForbidden}
onClick={() => emit.addGuess(n)} 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={[ 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 isForbidden
? 'border-red-700 text-red-700 opacity-40 cursor-not-allowed' ? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
: 'border-blue-400 text-blue-200 hover:bg-blue-600 hover:border-blue-600 active:scale-95', : 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
].join(' ')} ].join(' ')}
> >
{n} {n}
+69 -16
View File
@@ -21,30 +21,83 @@ interface Props {
myTurn: boolean; myTurn: boolean;
isPlayPhase: boolean; isPlayPhase: boolean;
playableKeys?: Set<string>; 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); const groups = groupedByColor(hand);
if (groups.length === 0) return null; 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 ( return (
<div className="flex flex-wrap gap-3 justify-center py-2"> <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 }) => ( {groups.map(({ color, keys }) => (
<div key={color} className="flex gap-1"> <div key={color} className="flex gap-1 items-end">
{keys.map((key) => { {keys.map((key) => (
// During guessing phase cards are visible at full opacity (just not clickable). <CardView key={key} size="xl" {...cardProps(key)} />
// Only dim cards during the play phase when they can't be played. ))}
const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key))); </div>
return ( ))}
<CardView </div>
key={key} ) : (
card={hand[key]} <MobileHand groups={groups} cardProps={cardProps} />
disabled={disabled} )}
onClick={() => emit.playCard(key)} </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>
))} ))}
</div> </div>
+9 -9
View File
@@ -26,9 +26,9 @@ export default function NameModal({ onClose }: Props) {
}; };
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-40 p-4"> <div className="fixed inset-0 bg-black/70 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"> <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="text-lg font-bold mb-4 text-white">Nazov hry</h2> <h2 className="font-serif text-xl mb-4 text-gold">Názov hry</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input <input
autoFocus autoFocus
@@ -36,23 +36,23 @@ export default function NameModal({ onClose }: Props) {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
maxLength={30} maxLength={30}
placeholder="Napr. Vecerna partia" placeholder="Napr. Večerná partia"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" 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"> <div className="flex gap-2 justify-end">
<button <button
type="button" type="button"
onClick={onClose} 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>
<button <button
type="submit" type="submit"
disabled={!name.trim()} 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> </button>
</div> </div>
</form> </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} onClick={onClose}
> >
<div <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()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={onClose} 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> </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"> <Section title="Karty">
<p>Hrá sa s <b>32-kartovým balíčkom</b> sedmových (slovenských/nemeckých) kariet.</p> <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"> <Section title="Priebeh kola">
<p className="font-semibold">1. Tipovanie</p> <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">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="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> <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"> <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>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> <p className="mt-2">Vyhráva hráč s najvyšším celkovým súčtom po 4 sériách.</p>
</Section> </Section>
<button <button
onClick={onClose} 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ť Zavrieť
</button> </button>
@@ -76,8 +76,8 @@ export default function RulesModal({ onClose }: Props) {
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="mb-4"> <div className="mb-4">
<h2 className="font-bold text-base text-green-400 mb-1">{title}</h2> <h2 className="font-serif text-base text-gold mb-1">{title}</h2>
<div className="text-gray-200">{children}</div> <div className="text-green-score">{children}</div>
</div> </div>
); );
} }
+174 -35
View File
@@ -4,53 +4,192 @@ import { computeTotal } from '../lib/standings';
interface Props { interface Props {
standings: number[][][]; standings: number[][][];
/** Tips per series/round/seat, same shape as standings. */
guesses?: number[][][];
players: PlayerInfo[]; 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 [open, setOpen] = useState(false);
const sorted = [...players] // Player columns in seat order; the local player's column is highlighted.
.map((p) => ({ ...p, total: computeTotal(standings, p.order) })) const cols = [...players].sort((a, b) => a.order - b.order);
.sort((a, b) => b.total - a.total); // 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;
return ( // Bigger, more legible type on the wide desktop sidebar; compact on mobile.
<div className="bg-slate-800 rounded-xl overflow-hidden"> const fz = {
<button head: desktop ? 11 : 10,
onClick={() => setOpen((o) => !o)} idx: desktop ? 13 : 9,
className="w-full flex justify-between items-center px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-slate-700" 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)` }}
> >
<span>Skore</span> <div />
<span>{open ? '▲' : '▼'}</span> {cols.map((p) => (
</button> <div
{open && ( key={p.order}
<table className="w-full text-sm text-center"> className={`text-center uppercase tracking-[.09em] truncate ${
<thead> p.order === myOrder ? 'text-gold' : 'text-green-dim'
<tr className="text-gray-400 border-b border-slate-700"> }`}
<th className="py-1 px-2 text-left">Hrac</th> style={{ fontSize: fz.head }}
{standings.map((_, si) => ( >
<th key={si} className="py-1 px-2">S{si + 1}</th> {p.name}
</div>
))} ))}
<th className="py-1 px-2">Spolu</th> </div>
</tr> <div className="h-px bg-gold/10 mb-1" />
</thead>
<tbody> {/* Completed rounds, grouped by series with a per-series summary row */}
{sorted.map((p) => ( {standings.flatMap((seriesRounds, si) => {
<tr key={p.order} className="border-b border-slate-700/50"> const priorRounds = seriesRoundOffsets[si];
<td className="py-1 px-2 text-left text-gray-200">{p.name}</td> const elems = seriesRounds.map((scores, lri) => (
{standings.map((series, si) => { <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 ( return (
<td key={si} className="py-1 px-2 text-gray-300"> <div
{computeTotal([series], p.order)} key={p.order}
</td> 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>
); );
})} })}
<td className="py-1 px-2 font-bold text-white">{p.total}</td> </div>
</tr> ));
// 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>
))} ))}
</tbody> </div>
</table>
)} <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-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 font-serif uppercase tracking-[.12em] text-[12px] text-gold"
>
<span>Skóre</span>
<span className="text-green-dim">{open ? '▲' : '▼'}</span>
</button>
{open && table}
</div> </div>
); );
} }
+53 -13
View File
@@ -1,26 +1,66 @@
import type { StashData } from '../types'; import type { PlayerInfo, StashData } from '../types';
import CardView from './CardView'; import CardView from './CardView';
interface Props { interface Props {
stash: StashData | null; stash: StashData | null;
players: PlayerInfo[];
myOrder: number;
} }
export default function Trick({ stash }: Props) { const ROTATIONS = [-3, 2, -1, 1];
const cards = stash // Entry direction by seat offset from me: 0=me(bottom) 1=left 2=top 3=right.
? [0, 1, 2, 3] const FLY_BY_OFFSET = ['fly-bottom', 'fly-left', 'fly-top', 'fly-right'];
.map((i) => (stash.first_player + i) % 4)
.map((order) => stash.cards[String(order)]) export default function Trick({ stash, players, myOrder }: Props) {
.filter(Boolean) // 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 ( return (
<div className="bg-green-900/60 rounded-xl p-4"> <div className="flex items-center justify-center">
<p className="text-xs text-green-300 mb-3 text-center">Aktualny stich</p> {playOrder.map((order, i) => {
<div className="flex gap-3 justify-center min-h-28"> const card = stash.cards[String(order)];
{cards.map((card, i) => ( // Only render cards that have actually been played — no placeholder slot
<CardView key={i} card={card} size="lg" /> // 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> </div>
); );
})}
</div>
);
} }
+67 -2
View File
@@ -2,6 +2,71 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { @layer base {
@apply bg-slate-900 text-white min-h-screen; html,
body,
#root {
@apply min-h-screen;
}
/* Single global type lever: enlarges all rem-based Tailwind text (menu/list/
auth/history screens). The game board uses fixed px + transform zoom, so it
stays pixel-precise. Bump this one value to scale the menus up or down. */
html {
font-size: 18px;
}
body {
@apply bg-table text-green-score font-sans;
background:
radial-gradient(ellipse at 50% -10%, rgba(48, 104, 69, 0.16), transparent 60%),
#090e0b;
background-attachment: fixed;
}
/* Form fields keep the velvet look across the app */
input::placeholder {
@apply text-green-dim/60;
}
}
/* Velvet-table animations (design handoff). Declared as raw CSS so they work
both via Tailwind's animate-* utilities and inline `animation:` strings. */
@keyframes tp {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes ci {
from { opacity: 0; transform: translateY(-6px) scale(0.9); }
to { opacity: 1; transform: none; }
}
@keyframes ar {
0%, 100% {
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18), 0 0 18px rgba(201, 168, 76, 0.5), 0 0 42px rgba(201, 168, 76, 0.2);
}
50% {
box-shadow: 0 0 0 5px rgba(201, 168, 76, 0.34), 0 0 32px rgba(201, 168, 76, 0.85), 0 0 56px rgba(201, 168, 76, 0.3);
}
}
@keyframes g1 {
0%, 100% { box-shadow: 0 0 18px rgba(201, 168, 76, 0.55), 0 6px 18px rgba(0, 0, 0, 0.55); }
50% { box-shadow: 0 0 34px rgba(201, 168, 76, 0.85), 0 6px 18px rgba(0, 0, 0, 0.55); }
}
/* A played card slides into the centre from the direction of its player. */
@keyframes fly-top {
from { opacity: 0; transform: translateY(-90px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-bottom {
from { opacity: 0; transform: translateY(90px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-left {
from { opacity: 0; transform: translateX(-110px) scale(0.82); }
to { opacity: 1; transform: none; }
}
@keyframes fly-right {
from { opacity: 0; transform: translateX(110px) scale(0.82); }
to { opacity: 1; transform: none; }
} }
+2
View File
@@ -47,6 +47,8 @@ export const emit = {
_pendingGid = gid; _pendingGid = gid;
socket.emit('rejoin_game', gid); socket.emit('rejoin_game', gid);
}, },
// Reopen a prematurely-ended game from history back into the lobby.
restoreGame: (gid: string) => socket.emit('restore_game', gid),
leaveGame: () => socket.emit('leave_game'), leaveGame: () => socket.emit('leave_game'),
endGame: (gid: string) => socket.emit('end_game', gid), endGame: (gid: string) => socket.emit('end_game', gid),
startGame: (gid: string) => socket.emit('start_game', gid), startGame: (gid: string) => socket.emit('start_game', gid),
+45
View File
@@ -0,0 +1,45 @@
import { useLayoutEffect, useRef, useState } from 'react';
/**
* Zooms the desktop board to fill the whole window. The board is a canvas of
* fixed height (`designHeight`) whose width is computed to span the viewport,
* and the scale is driven by height — so everything (cards, circles, text,
* header) grows and shrinks together while the felt always uses the full width.
*
* Returns the container ref (the viewport), the `scale` for `transform`, and
* `contentWidth` — the pre-scale canvas width (`viewportWidth / scale`) so that
* after scaling it exactly fills the viewport width.
*
* `deps` should change when the layout swaps (mobile↔desktop) so the observer
* re-attaches to the freshly rendered element.
*/
export function useFitScale(deps: unknown[] = [], designHeight = 860, maxScale = 2.6) {
const containerRef = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ scale: 1, contentWidth: 1280 });
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const availW = el.clientWidth;
const availH = el.clientHeight;
if (!availW || !availH) return;
const scale = Math.min(maxScale, availH / designHeight);
const contentWidth = availW / scale;
setBox((prev) =>
Math.abs(prev.scale - scale) > 0.004 || Math.abs(prev.contentWidth - contentWidth) > 1
? { scale, contentWidth }
: prev,
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { containerRef, scale: box.scale, contentWidth: box.contentWidth };
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
/** True on viewports >= 1024px — drives the desktop GameTable layout
* (score sidebar, larger oval, bigger cards). */
export function useIsDesktop(): boolean {
const query = '(min-width: 1024px)';
const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
setIsDesktop(mql.matches);
return () => mql.removeEventListener('change', handler);
}, []);
return isDesktop;
}
+49 -30
View File
@@ -2,13 +2,18 @@ import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import RulesModal from '../components/RulesModal';
type Mode = 'login' | 'register'; type Mode = 'login' | 'register';
const inputCls =
'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';
export default function Auth() { export default function Auth() {
const [mode, setMode] = useState<Mode>('login'); const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? ''); const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [showRules, setShowRules] = useState(false);
const registration = useGameStore((s) => s.registration); const registration = useGameStore((s) => s.registration);
const setRegistration = useGameStore((s) => s.setRegistration); const setRegistration = useGameStore((s) => s.setRegistration);
@@ -41,28 +46,28 @@ export default function Auth() {
}; };
return ( return (
<div className="max-w-sm mx-auto p-4 pt-10"> <div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
<h1 className="text-2xl font-bold text-center mb-2 tracking-wide">Bridzik</h1> <h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
<p className="text-center text-gray-400 text-sm mb-6"> <p className="text-center text-green-dim text-sm mb-7">
Prihlas sa kodom z aplikacie (napr. Google Authenticator). Prihlás sa kódom z aplikácie (napr. Google Authenticator).
</p> </p>
<div className="flex mb-6 rounded-xl overflow-hidden border border-slate-700"> <div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
<button <button
onClick={() => switchMode('login')} onClick={() => switchMode('login')}
className={`flex-1 py-2 text-sm font-semibold ${ className={`flex-1 py-2 text-sm font-serif tracking-wide ${
mode === 'login' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400' mode === 'login' ? 'bg-gold text-table' : 'bg-header text-green-dim'
}`} }`}
> >
Prihlasenie Prihlásenie
</button> </button>
<button <button
onClick={() => switchMode('register')} onClick={() => switchMode('register')}
className={`flex-1 py-2 text-sm font-semibold ${ className={`flex-1 py-2 text-sm font-serif tracking-wide ${
mode === 'register' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-gray-400' mode === 'register' ? 'bg-gold text-table' : 'bg-header text-green-dim'
}`} }`}
> >
Registracia Registrácia
</button> </button>
</div> </div>
@@ -74,8 +79,8 @@ export default function Auth() {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
maxLength={40} maxLength={40}
placeholder="Pouzivatelske meno" placeholder="Používateľské meno"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" className={inputCls}
/> />
<input <input
type="text" type="text"
@@ -83,15 +88,15 @@ export default function Auth() {
value={code} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
maxLength={6} maxLength={6}
placeholder="6-miestny kod" placeholder="6-miestny kód"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" className={`${inputCls} tracking-widest font-mono`}
/> />
<button <button
type="submit" type="submit"
disabled={!username.trim() || code.trim().length < 6} disabled={!username.trim() || code.trim().length < 6}
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40" className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
> >
Prihlasit Prihlásiť
</button> </button>
</form> </form>
)} )}
@@ -104,30 +109,30 @@ export default function Auth() {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
maxLength={40} maxLength={40}
placeholder="Zvol si pouzivatelske meno" placeholder="Zvoľ si používateľské meno"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500" className={inputCls}
/> />
<button <button
type="submit" type="submit"
disabled={!username.trim()} disabled={!username.trim()}
className="py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold disabled:opacity-40" className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
> >
Vytvorit ucet Vytvoriť účet
</button> </button>
</form> </form>
)} )}
{mode === 'register' && registration && ( {mode === 'register' && registration && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-gray-300"> <p className="text-sm text-green-score">
Naskenuj QR kod do autentifikacnej aplikacie a opis aktualny kod. Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.
</p> </p>
<div className="bg-white rounded-xl p-4 flex justify-center"> <div className="bg-white rounded-xl p-4 flex justify-center">
<QRCodeSVG value={registration.otpauth_uri} size={176} /> <QRCodeSVG value={registration.otpauth_uri} size={176} />
</div> </div>
<div className="text-xs text-gray-400 text-center"> <div className="text-xs text-green-dim text-center">
Alebo zadaj rucne kluc: Alebo zadaj ručne kľúč:
<span className="block font-mono text-gray-200 break-all mt-1"> <span className="block font-mono text-green-score break-all mt-1">
{registration.secret} {registration.secret}
</span> </span>
</div> </div>
@@ -139,19 +144,33 @@ export default function Auth() {
value={code} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
maxLength={6} maxLength={6}
placeholder="6-miestny kod" placeholder="6-miestny kód"
className="bg-slate-700 text-white rounded-lg px-4 py-2 outline-none focus:ring-2 focus:ring-blue-500 tracking-widest font-mono" className={`${inputCls} tracking-widest font-mono`}
/> />
<button <button
type="submit" type="submit"
disabled={code.trim().length < 6} disabled={code.trim().length < 6}
className="py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold disabled:opacity-40" className="py-3 rounded-xl bg-gold text-table font-serif font-semibold hover:bg-gold-bright disabled:opacity-40 transition-colors"
> >
Potvrdit a prihlasit Potvrdiť a prihlásiť
</button> </button>
</form> </form>
</div> </div>
)} )}
{/* Rules are reachable before login — quiet link with the velvet divider motif. */}
<div className="mt-10 flex items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
<button
onClick={() => setShowRules(true)}
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
>
Pravidlá hry
</button>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-gold/20" />
</div>
{showRules && <RulesModal onClose={() => setShowRules(false)} />}
</div> </div>
); );
} }
+21 -19
View File
@@ -24,23 +24,23 @@ export default function GameList() {
}; };
return ( return (
<div className="max-w-md mx-auto p-4 pt-8"> <div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold tracking-wide">Bridzik</h1> <h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-gray-400">{account?.username}</span> <span className="text-green-dim">{account?.username}</span>
<button onClick={() => navigate('/history')} className="text-blue-400 hover:text-blue-300"> <button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
Historia História
</button> </button>
<button onClick={handleLogout} className="text-gray-400 hover:text-white"> <button onClick={handleLogout} className="text-green-dim hover:text-gold">
Odhlasit Odhlásiť
</button> </button>
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 mb-6"> <div className="flex flex-col gap-3 mb-6">
{games.length === 0 && ( {games.length === 0 && (
<p className="text-center text-gray-500 py-4">Ziadne hry. Vytvor prvu!</p> <p className="text-center text-green-dim py-4">Žiadne hry. Vytvor prvú!</p>
)} )}
{games.map((g) => { {games.map((g) => {
const full = g.players.length >= 4; const full = g.players.length >= 4;
@@ -48,24 +48,26 @@ export default function GameList() {
!!account && g.players.some((p) => p.player_id === account.player_id); !!account && g.players.some((p) => p.player_id === account.player_id);
const canResume = g.started && isMember; const canResume = g.started && isMember;
const unavailable = !canResume && (full || g.started); const unavailable = !canResume && (full || g.started);
const label = canResume ? 'Pokracovat' : full ? 'Plna' : g.started ? 'Zacata' : 'Vstup'; const label = canResume ? 'Pokračovať' : full ? 'Plná' : g.started ? 'Začatá' : 'Vstúp';
return ( return (
<div <div
key={g.gid} key={g.gid}
className="flex items-center justify-between bg-slate-800 rounded-xl px-4 py-3" className="flex items-center justify-between bg-header border border-[#142018] rounded-xl px-4 py-3"
> >
<div> <div>
<p className="font-semibold">{g.name}</p> <p className="font-serif text-green-score">{g.name}</p>
<p className="text-xs text-gray-400"> <p className="text-xs text-green-dim">
{g.players.length}/4 hracov {g.players.length}/4 hráčov
{g.started ? ' · zacata' : ''} {g.started ? ' · začatá' : ''}
</p> </p>
</div> </div>
<button <button
disabled={unavailable} disabled={unavailable}
onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))} onClick={() => (canResume ? emit.rejoinGame(g.gid) : emit.registerPlayer(g.gid))}
className={`px-4 py-1.5 rounded-lg text-sm font-semibold disabled:opacity-40 disabled:cursor-default ${ className={`px-4 py-1.5 rounded-lg text-sm font-serif font-semibold disabled:opacity-40 disabled:cursor-default transition-colors ${
canResume ? 'bg-green-700 hover:bg-green-600' : 'bg-blue-600 hover:bg-blue-500' canResume
? 'bg-gold text-table hover:bg-gold-bright'
: 'border border-gold/40 text-gold hover:bg-gold hover:text-table'
}`} }`}
> >
{label} {label}
@@ -77,14 +79,14 @@ export default function GameList() {
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg mb-3" className="w-full py-2 rounded-xl bg-gold text-table font-serif font-semibold text-base mb-3 hover:bg-gold-bright transition-colors"
> >
+ Vytvorit novu hru Vytvoriť novú hru
</button> </button>
<button <button
onClick={() => setShowRules(true)} onClick={() => setShowRules(true)}
className="w-full py-2 rounded-xl bg-slate-700 hover:bg-slate-600 text-sm text-gray-300" className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
> >
Pravidlá hry Pravidlá hry
</button> </button>
+9 -7
View File
@@ -20,25 +20,27 @@ export default function GameOver({ players, standings }: Props) {
const medals = ['🥇', '🥈', '🥉', '']; const medals = ['🥇', '🥈', '🥉', ''];
return ( return (
<div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6"> <div className="max-w-md mx-auto p-4 pt-12 flex flex-col items-center gap-6 min-h-screen">
<h1 className="text-3xl font-bold">Koniec hry!</h1> <h1 className="font-serif text-3xl text-gold tracking-wide">Koniec hry</h1>
<div className="bg-slate-800 rounded-2xl w-full overflow-hidden"> <div className="w-full rounded-2xl overflow-hidden bg-header border border-[#142018]">
{totals.map((p, i) => ( {totals.map((p, i) => (
<div <div
key={p.order} key={p.order}
className="flex items-center justify-between px-5 py-3 border-b border-slate-700 last:border-0" className="flex items-center justify-between px-5 py-3 border-b border-gold/[.08] last:border-0"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl w-8">{medals[i]}</span> <span className="text-2xl w-8">{medals[i]}</span>
<span className="font-semibold">{p.name}</span> <span className="font-serif text-green-score">{p.name}</span>
</div> </div>
<span className="text-xl font-bold text-yellow-300">{p.total}</span> <span className={`font-serif text-xl ${i === 0 ? 'text-gold-bright' : 'text-gold'}`}>
{p.total}
</span>
</div> </div>
))} ))}
</div> </div>
<button <button
onClick={handleLeave} onClick={handleLeave}
className="w-full py-3 rounded-xl bg-blue-600 hover:bg-blue-500 font-bold text-lg" className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg hover:bg-gold-bright transition-colors"
> >
Domov Domov
</button> </button>
+256 -72
View File
@@ -4,17 +4,26 @@ import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame'; import { leaveGame } from '../lib/leaveGame';
import { computePlayable } from '../lib/gameRules'; import { computePlayable } from '../lib/gameRules';
import { computeTotal } from '../lib/standings';
import { useIsDesktop } from '../lib/useIsDesktop';
import { useFitScale } from '../lib/useFitScale';
import Hand from '../components/Hand'; import Hand from '../components/Hand';
import GuessControls from '../components/GuessControls'; import GuessControls from '../components/GuessControls';
import Trick from '../components/Trick'; import Trick from '../components/Trick';
import Standings from '../components/Standings'; import Standings from '../components/Standings';
import PlayerCircle from '../components/PlayerCircle';
import FaceDownCards from '../components/FaceDownCards';
import GameOver from './GameOver'; import GameOver from './GameOver';
import type { StashData } from '../types'; import type { PlayerInfo, StashData } from '../types';
const TRICK_LINGER_MS = 3000; const TRICK_LINGER_MS = 3000;
export default function GameTable() { export default function GameTable() {
const navigate = useNavigate(); const navigate = useNavigate();
const desktop = useIsDesktop();
// Zooms the whole desktop board to fill the window (full width + height), so
// cards, circles, text and the header all scale together. Up to 2.6×.
const { containerRef, scale, contentWidth } = useFitScale([desktop], 860, 2.6);
const myPlayer = useGameStore((s) => s.myPlayer); const myPlayer = useGameStore((s) => s.myPlayer);
const gameStatus = useGameStore((s) => s.gameStatus); const gameStatus = useGameStore((s) => s.gameStatus);
const hand = useGameStore((s) => s.hand); const hand = useGameStore((s) => s.hand);
@@ -37,7 +46,7 @@ export default function GameTable() {
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]); }, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
if (!gameStatus || !myPlayer) { if (!gameStatus || !myPlayer) {
return <p className="text-center text-gray-400 pt-20">Nacitava sa...</p>; return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa</p>;
} }
const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus; const { completed, players, series_number, round_number, cards_in_round, status } = gameStatus;
@@ -47,6 +56,7 @@ export default function GameTable() {
active_round_stashes, active_round_stashes,
active_stash, active_stash,
standings = [], standings = [],
standings_guesses = [],
} = status; } = status;
if (completed) { if (completed) {
@@ -57,89 +67,98 @@ export default function GameTable() {
const isPlayPhase = active_stash !== undefined; const isPlayPhase = active_stash !== undefined;
const myTurnToPlay = isPlayPhase && active_player === myOrder; const myTurnToPlay = isPlayPhase && active_player === myOrder;
// Show active stash if it has cards; otherwise show the lingered completed trick.
const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0; const activeCards = active_stash ? Object.keys(active_stash.cards).length : 0;
const displayedStash = activeCards > 0 ? active_stash : lingeredStash ?? undefined; const displayedStash: StashData | null =
activeCards > 0 && active_stash ? active_stash : lingeredStash ?? null;
// Highlight only cards the engine would accept
const playableKeys = myTurnToPlay && active_stash const playableKeys = myTurnToPlay && active_stash
? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null) ? computePlayable(hand, active_stash.cards[String(active_stash.first_player)]?.color ?? null)
: undefined; : undefined;
const activePlayerName = players.find((p) => p.order === active_player)?.name ?? ''; const activePlayerName = players.find((p) => p.order === active_player)?.name ?? '';
// Seat mapping relative to "Ty": left / across / right.
const seat = (offset: number): PlayerInfo | undefined =>
players.find((p) => p.order === (myOrder + offset) % 4);
const leftP = seat(1);
const topP = seat(2);
const rightP = seat(3);
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0);
const guessOf = (o?: number): number | null => {
if (o === undefined) return null;
const g = active_round_guesses?.[String(o)];
return g === undefined ? null : g;
};
const activeOf = (o?: number) => o !== undefined && active_player === o;
// Exact cards still in a player's hand: started with cards_in_round, lost one
// per completed trick, minus one more if they've already played this trick.
const completedTricks = (active_round_stashes ?? []).reduce((a, b) => a + b, 0);
const cardsInHandOf = (o?: number) => {
if (o === undefined) return 0;
const playedCurrent = active_stash?.cards[String(o)] ? 1 : 0;
return Math.max(0, cards_in_round - completedTricks - playedCurrent);
};
const handleLeave = () => leaveGame(navigate); const handleLeave = () => leaveGame(navigate);
const handleEnd = () => { const handleEnd = () => {
if (window.confirm('Naozaj ukoncit celu hru pre vsetkych?')) { if (window.confirm('Naozaj ukončiť celú hru pre všetkých?')) {
emit.endGame(gameStatus.gid); emit.endGame(gameStatus.gid);
} }
}; };
// The host can always end the game; other players only when the host is
// currently offline, so an abandoned game isn't stuck forever waiting for
// a host who won't come back, but it isn't open to casual misuse otherwise.
const hostConnected = players.find((p) => p.order === 0)?.connected ?? false;
const canEnd = myOrder === 0 || !hostConnected;
return ( // ── shared pieces ────────────────────────────────────────────────
<div className="max-w-lg mx-auto p-3 flex flex-col gap-3 pb-6"> const bannerText = activeOf(myOrder)
{/* Header */} ? isPlayPhase
<div className="flex items-center justify-between"> ? 'Zahraj kartu'
<div className="text-sm text-gray-400"> : 'Zadaj tip'
<span>Seria {series_number} / Kolo {round_number + 1}</span> : `${activePlayerName} ${isPlayPhase ? 'hrá' : 'tipuje'}`;
<span className="ml-2 text-gray-500">({cards_in_round} kopok)</span>
</div>
<div className="flex gap-3">
{myOrder === 0 && (
<button onClick={handleEnd} className="text-xs text-red-400 hover:text-red-300">
Ukoncit hru
</button>
)}
<button onClick={handleLeave} className="text-xs text-gray-500 hover:text-gray-300">
Odist
</button>
</div>
</div>
{/* Turn indicator */} const banner = (
<div className="bg-slate-800/60 rounded-lg px-3 py-2 text-sm text-center"> <div className="flex items-center justify-center gap-2">
{active_player === myOrder ? ( <span className="inline-block w-[7px] h-[7px] rounded-full bg-gold animate-tp flex-shrink-0" />
<span className="text-yellow-300 font-semibold"> <span className="font-serif italic text-[13px] text-gold-dim tracking-[.03em]">{bannerText}</span>
{!isPlayPhase ? 'Zadaj svoj tip' : 'Zahraj kartu'}
</span>
) : (
<span className="text-gray-400">
Na rade: <span className="text-white">{activePlayerName}</span>
</span>
)}
</div> </div>
{/* Guesses summary (always shown when available) */}
{active_round_guesses && (
<div className="bg-slate-800/60 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 mb-1">Tipy:</p>
<div className="flex gap-3 flex-wrap">
{players.map((p) => {
const guess = active_round_guesses[String(p.order)];
const wins = active_round_stashes?.[p.order] ?? 0;
return (
<span key={p.order} className="text-sm">
<span className="text-gray-300">{p.name}:</span>{' '}
{guess !== undefined ? (
<span className="text-white font-semibold">
{isPlayPhase ? `${wins}/${guess}` : guess}
</span>
) : (
<span className="text-gray-500">?</span>
)}
</span>
); );
})}
const opponents = players
.filter((p) => p.order !== myOrder)
.sort((a, b) => a.order - b.order);
const totalsRow = (compact: boolean) => (
<div className="flex items-center justify-between gap-2">
{opponents.map((p) => (
<div key={p.order} className="text-center">
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
{p.name}
</div>
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, p.order)}
</div> </div>
</div> </div>
)} ))}
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
{myPlayer.name}
</div>
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
{computeTotal(standings, myOrder)}
</div>
</div>
</div>
);
{/* Active stash (trick) — container always visible during play phase, cards linger 3 s */} // Center of the oval: trick during play, guess controls during bidding.
{isPlayPhase && ( const ovalContent = isPlayPhase ? (
<Trick stash={displayedStash ?? null} /> <Trick stash={displayedStash} players={players} myOrder={myOrder} />
)} ) : (
active_round_guesses !== undefined && active_player !== undefined ? (
{/* Guess phase controls */}
{!isPlayPhase && active_round_guesses !== undefined && active_player !== undefined && (
<GuessControls <GuessControls
cardsInRound={cards_in_round} cardsInRound={cards_in_round}
guesses={active_round_guesses} guesses={active_round_guesses}
@@ -147,16 +166,181 @@ export default function GameTable() {
activePlayer={active_player} activePlayer={active_player}
activePlayerName={activePlayerName} activePlayerName={activePlayerName}
/> />
)} ) : null
);
{/* Hand */} const topSeat = (
<div> <div className="flex flex-col items-center gap-1.5">
<p className="text-xs text-gray-400 mb-1 text-center">Tvoje karty</p> <PlayerCircle
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} /> name={topP?.name ?? '—'}
won={wonOf(topP?.order)}
guess={guessOf(topP?.order)}
active={activeOf(topP?.order)}
size={desktop ? 64 : 52}
/>
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div>
);
const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5">
<PlayerCircle
name={p?.name ?? '—'}
won={wonOf(p?.order)}
guess={guessOf(p?.order)}
active={activeOf(p?.order)}
size={desktop ? 60 : 48}
/>
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div>
);
const meSeat = (
<div className="flex justify-center">
<PlayerCircle
name={myPlayer.name}
won={wonOf(myOrder)}
guess={guessOf(myOrder)}
active={activeOf(myOrder)}
size={desktop ? 70 : 58}
/>
</div>
);
const handArea = (
<Hand hand={hand} myTurn={myTurnToPlay} isPlayPhase={isPlayPhase} playableKeys={playableKeys} desktop={desktop} />
);
// ── DESKTOP LAYOUT ───────────────────────────────────────────────
if (desktop) {
return (
<div ref={containerRef} className="h-[100dvh] w-full overflow-hidden bg-table">
{/* Design canvas — fixed height, width spans the viewport; scaled as one
unit so the whole board (and header) zooms with the window. */}
<div
className="flex"
style={{ width: contentWidth, height: 860, transform: `scale(${scale})`, transformOrigin: 'top left' }}
>
{/* main */}
<div className="flex-1 min-w-0 flex flex-col">
{/* header */}
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
Bridžik
</span>
<div className="w-px h-[22px] bg-[#1a3a22]" />
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex-1 flex items-center justify-center">{banner}</div>
{totalsRow(true)}
<div className="w-px h-[22px] bg-[#1a3a22]" />
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
Odísť
</button>
</div> </div>
{/* Standings */} {/* game content — players hug the edges so the felt uses full width */}
<Standings standings={standings} players={players} /> <div className="flex-1 min-h-0 flex flex-col justify-center gap-3 px-16 py-4">
{topSeat}
<div className="flex items-center justify-center gap-16">
{sideSeat(leftP)}
<div
className="flex items-center justify-center rounded-full"
style={{
width: 620,
height: 372,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 10px 48px rgba(0,0,0,.72),0 0 0 3px rgba(0,0,0,.55),0 0 0 6px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
{sideSeat(rightP)}
</div>
{meSeat}
</div>
{handArea}
</div>
{/* sidebar */}
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} desktop />
</div>
</div>
);
}
// ── MOBILE LAYOUT ────────────────────────────────────────────────
return (
<div className="max-w-lg mx-auto min-h-screen flex flex-col">
{/* header */}
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
<div className="flex items-center justify-between mb-2.5">
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
Séria {series_number + 1} · Kolo {round_number + 1}
</span>
<div className="flex items-center gap-3">
{canEnd && (
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
Ukončiť
</button>
)}
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
Odísť
</button>
</div>
</div>
{totalsRow(false)}
</div>
{/* turn banner */}
<div
className="py-[9px] px-4 border-b border-[#152a1a]"
style={{ background: 'linear-gradient(90deg,#09190d,#14301e,#09190d)' }}
>
{banner}
</div>
{/* game area */}
<div className="flex-1 bg-table px-2.5 pt-2.5 pb-1.5 flex flex-col">
<div className="flex flex-col items-center mb-1.5">{topSeat}</div>
<div className="flex items-center gap-1.5 mb-2">
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(leftP)}</div>
<div
className="flex-1 flex items-center justify-center rounded-full"
style={{
minHeight: 192,
background:
'radial-gradient(ellipse at 42% 38%,#306845 0%,#1e5030 38%,#122e1c 72%,#091e12 100%)',
boxShadow:
'inset 0 6px 32px rgba(0,0,0,.7),0 0 0 2px rgba(0,0,0,.5),0 0 0 4px rgba(201,168,76,.1)',
}}
>
{ovalContent}
</div>
<div className="w-[54px] flex-shrink-0 flex justify-center">{sideSeat(rightP)}</div>
</div>
<div className="mb-1.5">{meSeat}</div>
</div>
{handArea}
{/* score */}
<div className="bg-table px-3 pb-4 pt-1">
<Standings standings={standings} guesses={standings_guesses} players={players} myOrder={myOrder} />
</div>
</div> </div>
); );
} }
+248 -53
View File
@@ -1,7 +1,9 @@
import { useEffect } from 'react'; import { Fragment, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit, socket } from '../lib/socket';
import { useIsDesktop } from '../lib/useIsDesktop';
import type { GameDetail, GameDetailRound } from '../types';
function fmtDate(iso: string | null): string { function fmtDate(iso: string | null): string {
if (!iso) return '—'; if (!iso) return '—';
@@ -20,78 +22,271 @@ export default function History() {
return () => setGameDetail(null); return () => setGameDetail(null);
}, [setGameDetail]); }, [setGameDetail]);
// After a prematurely-ended game is reopened, the server confirms with
// `game_restored`; jump to the lobby where it now shows as resumable.
useEffect(() => {
const onRestored = () => navigate('/');
socket.on('game_restored', onRestored);
return () => {
socket.off('game_restored', onRestored);
};
}, [navigate]);
// --- detail view --- // --- detail view ---
if (detail) { if (detail) {
return ( return <GameDetailView detail={detail} onBack={() => setGameDetail(null)} />;
<div className="max-w-md mx-auto p-4 pt-8">
<button
onClick={() => setGameDetail(null)}
className="text-sm text-gray-400 hover:text-white mb-4"
>
Spat na zoznam
</button>
<h1 className="text-xl font-bold mb-1">Detail hry</h1>
<p className="text-xs text-gray-400 mb-4">{fmtDate(detail.created_at)}</p>
<div className="bg-slate-800 rounded-xl overflow-hidden text-sm">
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-2 text-xs text-gray-400 border-b border-slate-700">
<span>S/K</span>
<span>Hrac</span>
<span className="text-right">Tip</span>
<span className="text-right">Body</span>
</div>
{detail.rounds.map((r, i) => (
<div
key={i}
className="grid grid-cols-[auto_1fr_auto_auto] gap-2 px-3 py-1.5 border-b border-slate-700/50 last:border-0"
>
<span className="text-gray-500">
{r.series_number}/{r.round_number}
</span>
<span className="truncate">{r.username ?? `#${r.player_id}`}</span>
<span className="text-right font-mono">{r.guess}</span>
<span className={`text-right font-mono ${r.won ? 'text-green-400' : 'text-gray-500'}`}>
{r.points}
</span>
</div>
))}
</div>
</div>
);
} }
// --- list view --- // --- list view ---
return ( return (
<div className="max-w-md mx-auto p-4 pt-8"> <div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold">Moja historia</h1> <h1 className="font-serif text-2xl text-gold">Moja história</h1>
<button onClick={() => navigate('/')} className="text-sm text-gray-400 hover:text-white"> <button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
Spat Späť
</button> </button>
</div> </div>
{history.length === 0 && ( {history.length === 0 && (
<p className="text-center text-gray-500 py-6">Zatial ziadne odohrane hry.</p> <p className="text-center text-green-dim py-6">Zatiaľ žiadne odohrané hry.</p>
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{history.map((g) => ( {history.map((g) => (
<button <div
key={g.gid} key={g.gid}
onClick={() => emit.getGameDetail(g.gid)} className="flex items-stretch bg-header border border-[#142018] rounded-xl overflow-hidden"
className="text-left bg-slate-800 hover:bg-slate-700 rounded-xl px-4 py-3"
> >
<div className="flex items-center justify-between"> <button
<span className="text-sm text-gray-200">{fmtDate(g.created_at)}</span> onClick={() => emit.getGameDetail(g.gid)}
<span className="text-sm font-semibold text-green-400">{g.my_points} b.</span> className="flex-1 min-w-0 text-left px-4 py-3 hover:bg-white/[.02] transition-colors"
</div> >
<p className="text-xs text-gray-400 mt-1 truncate">{g.players.join(', ')}</p> <p className="font-serif text-green-score truncate">{g.name || 'Hra'}</p>
<p className="text-[11px] text-gray-500 mt-0.5"> <p className="text-xs text-green-dim mt-1 truncate">{g.players.join(', ')}</p>
{g.ended_at ? 'dohrana' : 'nedohrana'} <p className="text-xs text-[#7a7058] mt-0.5">
{fmtDate(g.created_at)} · {g.completed ? 'dohraná' : 'predčasne ukončená'}
</p> </p>
</button> </button>
<div className="flex flex-col items-end justify-center gap-2 py-3 pl-2 pr-3">
<span className="text-base font-serif text-gold whitespace-nowrap leading-none">
{g.my_points}
<span className="text-[11px] text-green-dim ml-0.5">b.</span>
</span>
{!g.completed && (
<button
onClick={() => emit.restoreGame(g.gid)}
className="px-4 py-1.5 rounded-lg text-sm font-serif font-semibold bg-gold text-table hover:bg-gold-bright transition-colors whitespace-nowrap"
>
Obnoviť
</button>
)}
</div>
</div>
))} ))}
</div> </div>
</div> </div>
); );
} }
const SEP = '1px solid rgba(201,168,76,.16)'; // vertical divider between players
const DESKTOP_COLS = '28px repeat(8,1fr)'; // index + 4 players × 2 series columns
const MOBILE_COLS = '28px repeat(4,1fr)'; // index + 4 players (one series stacked)
/** Scoreboard-style detail. Desktop: 4 player columns in the header (total
* beside the name), and under each a pair of series side by side — series 1 &
* 2 on top, 3 & 4 below, separated by a blank row. Mobile: the same 4 player
* columns, but the series stack one below another. Per round a cell shows the
* points (hit bid) or the struck-through bid (missed → 0); each series ends
* with a Σ total. */
function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () => void }) {
const desktop = useIsDesktop();
const seats = detail.players; // seat order 0..3
const seatIds = seats.map((p) => p.player_id);
const totals = seatIds.map((pid) =>
detail.rounds.reduce((a, r) => (r.player_id === pid ? a + r.points : a), 0),
);
// series -> round -> playerId -> round entry
const bySeries = useMemo(() => {
const m = new Map<number, Map<number, Map<number, GameDetailRound>>>();
for (const r of detail.rounds) {
let rounds = m.get(r.series_number);
if (!rounds) m.set(r.series_number, (rounds = new Map()));
let byPlayer = rounds.get(r.round_number);
if (!byPlayer) rounds.set(r.round_number, (byPlayer = new Map()));
byPlayer.set(r.player_id, r);
}
return m;
}, [detail.rounds]);
const seriesNums = [...bySeries.keys()].sort((a, b) => a - b);
const roundsOf = (s: number | undefined) =>
s === undefined ? [] : [...(bySeries.get(s)?.keys() ?? [])];
const cellNode = (s: number | undefined, rn: number, seat: number) => {
const r = s === undefined ? undefined : bySeries.get(s)?.get(rn)?.get(seatIds[seat]);
if (!r) return null;
return r.won ? (
<span className="font-serif" style={{ fontSize: 14, color: '#c8bb95' }}>{r.points}</span>
) : (
<span className="font-serif line-through" style={{ fontSize: 14, color: '#7a6e4a' }}>{r.guess}</span>
);
};
const seriesTotal = (s: number | undefined, seat: number) =>
s === undefined
? ''
: [...(bySeries.get(s)?.values() ?? [])].reduce(
(a, byP) => a + (byP.get(seatIds[seat])?.points ?? 0),
0,
);
// Header: player names with their grand total beside the name (both layouts).
const header = (
<div
className="border-b border-gold/[.18]"
style={{ display: 'grid', gridTemplateColumns: MOBILE_COLS, padding: '9px 8px' }}
>
<div />
{seats.map((p, c) => (
<div
key={c}
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: 6,
borderLeft: c > 0 ? SEP : undefined,
}}
>
<span className="uppercase text-gold" style={{ letterSpacing: '.06em', fontSize: 11 }}>
{p.username}
</span>
<span className="font-serif text-gold-dim" style={{ fontWeight: 700, fontSize: 16 }}>
{totals[c]}
</span>
</div>
))}
</div>
);
// A round row: index column + one cell per (player × series) in `cols`.
const roundRow = (rn: number, cols: (number | undefined)[]) => (
<div
key={rn}
className="border-b border-gold/[.04]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '3px 8px',
}}
>
<div style={{ textAlign: 'center', fontSize: 10, color: '#7a7252' }}>{rn + 1}</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div key={`${c}-${si}`} style={{ textAlign: 'center', borderLeft: c > 0 && si === 0 ? SEP : undefined }}>
{cellNode(s, rn, c)}
</div>
)),
)}
</div>
);
// Σ row: per-series totals for each player.
const sigmaRow = (cols: (number | undefined)[]) => (
<div
className="bg-gold/[.08] border-b border-gold/[.14]"
style={{
display: 'grid',
gridTemplateColumns: desktop ? DESKTOP_COLS : MOBILE_COLS,
alignItems: 'center',
padding: '5px 8px',
}}
>
<div className="text-green-dim" style={{ textAlign: 'center', fontSize: 11 }}>Σ</div>
{seats.map((_, c) =>
cols.map((s, si) => (
<div
key={`${c}-${si}`}
className="font-serif text-green-score"
style={{ textAlign: 'center', fontWeight: 600, fontSize: 14, borderLeft: c > 0 && si === 0 ? SEP : undefined }}
>
{seriesTotal(s, c)}
</div>
)),
)}
</div>
);
// Tiny row telling which series each sub-column is (desktop only — always a
// fixed pair, sB may be absent for a trailing odd series).
const seriesTags = (sA: number, sB: number | undefined) => (
<div
className="border-b border-gold/10"
style={{ display: 'grid', gridTemplateColumns: DESKTOP_COLS, padding: '5px 8px 3px' }}
>
<div />
{seats.map((_, c) => (
<Fragment key={c}>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064', borderLeft: c > 0 ? SEP : undefined }}>
{sA + 1}
</div>
<div style={{ textAlign: 'center', fontSize: 10, color: '#8a8064' }}>
{sB !== undefined ? sB + 1 : ''}
</div>
</Fragment>
))}
</div>
);
// Desktop: pair series side by side (S1|S2, then S3|S4) with a blank row between.
const desktopBody = (() => {
const blocks: number[][] = [];
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
return blocks.map((block, bi) => {
const [sA, sB] = [block[0], block[1]];
const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
return (
<Fragment key={bi}>
{seriesTags(sA, sB)}
{roundNums.map((rn) => roundRow(rn, [sA, sB]))}
{sigmaRow([sA, sB])}
{bi < blocks.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
})();
// Mobile: one series per block, stacked vertically, 4 player columns each.
const mobileBody = seriesNums.map((s, si) => {
const roundNums = roundsOf(s).sort((a, b) => a - b);
return (
<Fragment key={s}>
<div style={{ padding: '7px 8px 3px', fontSize: 10, textTransform: 'uppercase', letterSpacing: '.09em', color: '#8a8064' }}>
Séria {s + 1}
</div>
{roundNums.map((rn) => roundRow(rn, [s]))}
{sigmaRow([s])}
{si < seriesNums.length - 1 && <div style={{ height: 12 }} />}
</Fragment>
);
});
return (
<div className="max-w-lg mx-auto p-4 pt-8 min-h-screen">
<button onClick={onBack} className="text-sm text-green-dim hover:text-gold mb-4">
Späť na zoznam
</button>
<h1 className="font-serif text-2xl text-gold mb-1 truncate">{detail.name || 'Detail hry'}</h1>
<p className="text-xs text-green-dim mb-4">{fmtDate(detail.created_at)}</p>
{/* Desktop packs 8 columns → allow horizontal scroll on narrow widths;
mobile uses only 4 columns and fits the phone, so no scroll. */}
<div className={`bg-header border border-[#142018] rounded-xl ${desktop ? 'overflow-x-auto' : ''}`}>
<div style={desktop ? { minWidth: 440 } : undefined}>
{header}
{desktop ? desktopBody : mobileBody}
</div>
</div>
</div>
);
}
+16 -16
View File
@@ -25,37 +25,37 @@ export default function Lobby() {
}; };
return ( return (
<div className="max-w-md mx-auto p-4 pt-8"> <div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold">{game?.name ?? 'Hra'}</h1> <h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
<button onClick={handleLeave} className="text-sm text-gray-400 hover:text-white"> <button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
Odist Odísť
</button> </button>
</div> </div>
<div className="bg-slate-800 rounded-xl p-4 mb-4 flex items-center justify-between"> <div className="bg-header border border-[#142018] rounded-xl p-4 mb-4 flex items-center justify-between">
<div> <div>
<p className="text-xs text-gray-400 mb-1">Kod hry</p> <p className="text-xs uppercase tracking-[.1em] text-green-dim mb-1">Kód hry</p>
<p className="font-mono text-sm text-gray-200 break-all">{gid}</p> <p className="font-mono text-sm text-green-score break-all">{gid}</p>
</div> </div>
<button <button
onClick={handleCopyCode} onClick={handleCopyCode}
className="ml-3 px-3 py-1 rounded-lg text-sm bg-slate-700 hover:bg-slate-600" className="ml-3 px-3 py-1 rounded-lg text-sm border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
> >
Kopirovat Kopírovať
</button> </button>
</div> </div>
<div className="bg-slate-800 rounded-xl p-4 mb-6 flex flex-col gap-3"> <div className="bg-header border border-[#142018] rounded-xl p-4 mb-6 flex flex-col gap-3">
{[0, 1, 2, 3].map((order) => { {[0, 1, 2, 3].map((order) => {
const p = players.find((pl) => pl.order === order); const p = players.find((pl) => pl.order === order);
return ( return (
<div key={order} className="flex items-center gap-3"> <div key={order} className="flex items-center gap-3">
<span className={`text-lg ${p ? 'text-green-400' : 'text-gray-600'}`}> <span className={`text-lg ${p ? 'text-gold' : 'text-[#7a7058]'}`}>
{p ? '' : '○'} {p ? '' : '○'}
</span> </span>
<span className={p ? 'text-white' : 'text-gray-500 italic'}> <span className={p ? 'font-serif text-green-score' : 'text-green-dim italic'}>
{p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Caka sa...'} {p ? `${p.name}${myPlayer?.order === p.order ? ' (ty)' : ''}` : 'Čaká sa'}
</span> </span>
</div> </div>
); );
@@ -65,9 +65,9 @@ export default function Lobby() {
<button <button
disabled={!canStart} disabled={!canStart}
onClick={() => gid && emit.startGame(gid)} onClick={() => gid && emit.startGame(gid)}
className="w-full py-3 rounded-xl bg-green-700 hover:bg-green-600 font-bold text-lg disabled:opacity-40 disabled:cursor-default" className="w-full py-3 rounded-xl bg-gold text-table font-serif font-semibold text-lg disabled:opacity-40 disabled:cursor-default hover:bg-gold-bright transition-colors"
> >
{isHost ? (canStart ? 'Zacat hru' : `Caka sa na hracov (${players.length}/4)`) : 'Caka sa na hosta...'} {isHost ? (canStart ? 'Začať hru' : `Čaká sa na hráčov (${players.length}/4)`) : 'Čaká sa na hostiteľa…'}
</button> </button>
</div> </div>
); );
+7
View File
@@ -32,6 +32,9 @@ export interface GameStatusDetail {
active_stash?: StashData; active_stash?: StashData;
previous_stash?: StashData; previous_stash?: StashData;
standings: number[][][]; standings: number[][][];
/** Tips per series/round/seat, same shape as standings. Used to show the
* struck-through tip in place of 0 when a tip failed. */
standings_guesses?: number[][][];
} }
export interface GameStatusPayload { export interface GameStatusPayload {
@@ -70,10 +73,13 @@ export interface Registration {
export interface HistoryGame { export interface HistoryGame {
gid: string; gid: string;
name: string;
created_at: string | null; created_at: string | null;
ended_at: string | null; ended_at: string | null;
players: string[]; players: string[];
my_points: number; my_points: number;
/** True = dohraná naplno; false = predčasne ukončená (dá sa obnoviť do lobby). */
completed: boolean;
} }
export interface GameDetailRound { export interface GameDetailRound {
@@ -88,6 +94,7 @@ export interface GameDetailRound {
export interface GameDetail { export interface GameDetail {
gid: string; gid: string;
name: string;
created_at: string | null; created_at: string | null;
ended_at: string | null; ended_at: string | null;
players: { player_id: number; username: string }[]; players: { player_id: number; username: string }[];
+58 -1
View File
@@ -1,6 +1,63 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./index.html', './src/**/*.{ts,tsx}'], content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} }, theme: {
extend: {
colors: {
// Velvet table palette (design handoff: "01 — Sametový stôl")
table: '#090e0b',
header: '#070c09',
circle: '#0c1a0f',
'circle-active': '#0e2015',
gold: '#c9a84c',
'gold-bright': '#f0d060',
'gold-dim': '#e8c14a',
// Secondary text is warm cream (not green) for legibility on the dark
// table — the green is reserved for structure (felt, opponent cards).
'green-dim': '#9c906c',
'green-score': '#d8cba6',
'green-circle': '#c2b58c',
},
fontFamily: {
serif: ['"Playfair Display"', 'Georgia', 'serif'],
sans: ['"DM Sans"', 'system-ui', 'sans-serif'],
},
keyframes: {
// turn-pulse — blinking dot / placeholder slot
tp: { '0%,100%': { opacity: '1' }, '50%': { opacity: '.5' } },
// card-in — card lands on the table
ci: {
from: { opacity: '0', transform: 'translateY(-6px) scale(.9)' },
to: { opacity: '1', transform: 'none' },
},
// active-ring — glowing gold ring around the active player circle
ar: {
'0%,100%': {
boxShadow:
'0 0 0 3px rgba(201,168,76,.18),0 0 18px rgba(201,168,76,.5),0 0 42px rgba(201,168,76,.2)',
},
'50%': {
boxShadow:
'0 0 0 5px rgba(201,168,76,.34),0 0 32px rgba(201,168,76,.85),0 0 56px rgba(201,168,76,.3)',
},
},
// glow-1 — gold glow border on a playable card
g1: {
'0%,100%': {
boxShadow: '0 0 18px rgba(201,168,76,.55),0 6px 18px rgba(0,0,0,.55)',
},
'50%': {
boxShadow: '0 0 34px rgba(201,168,76,.85),0 6px 18px rgba(0,0,0,.55)',
},
},
},
animation: {
tp: 'tp 1.8s ease-in-out infinite',
ci: 'ci .3s ease both',
ar: 'ar 2.2s ease-in-out infinite',
g1: 'g1 2.2s ease-in-out infinite',
},
},
},
plugins: [], plugins: [],
}; };
+7
View File
@@ -25,6 +25,13 @@ export default defineConfig({
], ],
server: { server: {
host: true, host: true,
// Docker bind mounts on Windows/macOS don't forward native FS events into the
// container, so Vite's watcher never fires and HMR appears "stuck". Polling the
// mounted files makes hot reload work without restarting the container.
watch: {
usePolling: true,
interval: 200,
},
proxy: { proxy: {
'/socket.io': { '/socket.io': {
// Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL // Local dev defaults to localhost; docker-compose sets VITE_BACKEND_URL
+28 -2
View File
@@ -143,15 +143,41 @@ class HistoryCase(unittest.TestCase):
run(history.mark_game_ended(gid)) run(history.mark_game_ended(gid))
self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games()))) self.assertFalse(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
def test_reopen_prematurely_ended_game(self):
ids = self._make_players()
gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Vzdana", ids))
run(history.record_completed_rounds(gid, make_core(completed=False)))
run(history.mark_game_ended(gid))
# V historii sa ukazuje ako predcasne ukoncena (nie naplno dohrana).
rows = run(history.get_player_history(ids[0]))
mine = next(g for g in rows if g["gid"] == gid)
self.assertFalse(mine["completed"])
# Cudzi hrac ju obnovit nemoze.
outsider = self._make_players(1)[0]
self.assertIsNone(run(history.reopen_game(gid, outsider)))
# Clen ju obnovi -> ended_at sa zmaze a hra je zas medzi nedohratymi.
info = run(history.reopen_game(gid, ids[0]))
self.assertIsNotNone(info)
self.assertEqual(len(info["seats"]), 4)
self.assertTrue(any(g["gid"] == gid for g in run(history.get_unfinished_games())))
# A teda uz nie je v historii (zobrazuju sa iba ukoncene hry).
self.assertFalse(any(g["gid"] == gid for g in run(history.get_player_history(ids[0]))))
def test_standings_from_db(self): def test_standings_from_db(self):
ids = self._make_players() ids = self._make_players()
gid = str(uuid.uuid4()) gid = str(uuid.uuid4())
run(history.record_game_started(gid, "Test", ids)) run(history.record_game_started(gid, "Test", ids))
run(history.record_completed_rounds(gid, make_core())) run(history.record_completed_rounds(gid, make_core()))
standings = run(history.get_standings(gid)) standings, guesses = run(history.get_standings(gid))
# 1 seria, 1 kolo, body podla sedadiel zo stubu [12, 0, 10, 11]. # 1 seria, 1 kolo. Body podla sedadiel zo stubu [12, 0, 10, 11],
# tipy zo stubu {0: 2, 1: 1, 2: 0, 3: 1}.
self.assertEqual(standings, [[[12, 0, 10, 11]]]) self.assertEqual(standings, [[[12, 0, 10, 11]]])
self.assertEqual(guesses, [[[2, 1, 0, 1]]])
def test_player_history(self): def test_player_history(self):
ids = self._make_players() ids = self._make_players()