diff --git a/api/__init__.py b/api/__init__.py
index 5ed2178..a7ea0cb 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -146,7 +146,7 @@ async def send_game_status(gid: str):
status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
# Use DB-backed standings so the score is correct even after a server 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(
"game_status",
{
@@ -180,9 +180,11 @@ async def send_error(sid: str, message: str):
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
- 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]
else:
await sio.emit(
@@ -212,14 +214,21 @@ async def _restore_unfinished_games():
for info in await history.get_unfinished_games():
if info["gid"] in games:
continue
- game = Game(info["gid"], info["name"])
- game.bridzik_core = info["core"]
- game.started = True
- for seat, (pid, uname) in enumerate(info["seats"]):
- player = Player(None, uname, seat, pid)
- player.connected = False
- game.players.append(player)
- games[info["gid"]] = game
+ _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.bridzik_core = info["core"]
+ game.started = True
+ for seat, (pid, uname) in enumerate(info["seats"]):
+ player = Player(None, uname, seat, pid)
+ player.connected = False
+ game.players.append(player)
+ games[info["gid"]] = game
+ return game
# --- connection lifecycle -------------------------------------------------
@@ -382,13 +391,13 @@ async def start_game(sid, gid):
@sio.on("end_game")
async def end_game(sid, gid):
- """Host (seat 0) permanently ends a game that won't be finished. Marks it
- ended in the DB (so it won't be restored) and sends everyone back to the lobby."""
+ """Any seated player can permanently end a game that won't be finished --
+ 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)
if sess is None or sess["gid"] != gid:
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)
if game is None:
return await send_error(sid, "Hra neexistuje.")
@@ -471,6 +480,27 @@ async def rejoin_game(sid, gid):
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) -
@sio.on("game_status")
diff --git a/api/history.py b/api/history.py
index a0c677d..188c0dd 100644
--- a/api/history.py
+++ b/api/history.py
@@ -9,10 +9,20 @@ from random import shuffle
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.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:
"""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
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()
-async def get_standings(gid: str) -> list[list[list[int]]]:
- """Body po seriach/kolach z DB v tvare, ktory caka frontend: standings[serie][kolo]
- = [body sedadiel 0..3]. Pouziva sa v game_status, aby skore sedelo aj po restarte.
+async def get_standings(gid: str) -> tuple[list[list[list[int]]], list[list[list[int]]]]:
+ """Body aj tipy po seriach/kolach z DB, oboje v tvare ktory caka frontend:
+ `[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:
game = await session.get(Game, gid)
if game is None:
- return []
+ return [], []
seat_of = {
game.player0_id: 0,
game.player1_id: 1,
@@ -102,18 +114,23 @@ async def get_standings(gid: str) -> list[list[list[int]]]:
)
).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:
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)
if seat is not None:
points[seat] = gz.points
+ tips[seat] = gz.guess
- return [
- [series_map[s][r] for r in sorted(series_map[s])]
- for s in sorted(series_map)
- ]
+ points_table: list[list[list[int]]] = []
+ guesses_table: list[list[list[int]]] = []
+ 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]:
@@ -122,17 +139,24 @@ async def get_player_history(player_id: int) -> list[dict]:
stmt = (
select(Game)
.where(
+ Game.ended_at.is_not(None), # iba ukoncene hry
or_(
Game.player0_id == player_id,
Game.player1_id == player_id,
Game.player2_id == player_id,
Game.player3_id == player_id,
- )
+ ),
)
.order_by(Game.created_at.desc())
)
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 = []
for g in games:
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(
{
"gid": g.id,
+ "name": g.name,
"created_at": g.created_at.isoformat() if g.created_at else None,
"ended_at": g.ended_at.isoformat() if g.ended_at else None,
"players": [usernames[pid] for pid in seat_ids],
"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
@@ -173,6 +200,7 @@ async def get_game_detail(gid: str) -> dict | None:
return {
"gid": game.id,
+ "name": game.name,
"created_at": game.created_at.isoformat() if game.created_at else None,
"ended_at": game.ended_at.isoformat() if game.ended_at else None,
"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}
+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 ---------------------------------------------------------------
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:
game = await session.get(Game, gid)
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()
async def get_unfinished_games() -> list[dict]:
- """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.
- """
+ """Nedohrate hry (ended_at IS NULL) aj s obnovenym jadrom -- na obnovu pri starte."""
async with async_session() as session:
games = (
await session.scalars(select(Game).where(Game.ended_at.is_(None)))
).all()
-
- 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
+ return [await _restore_info(session, g) for g in games]
diff --git a/bridzik.py b/bridzik.py
index cde1af3..062767e 100644
--- a/bridzik.py
+++ b/bridzik.py
@@ -82,6 +82,13 @@ class Card():
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():
def __init__(self, shuffler=shuffle):
self.shuffler = shuffler
@@ -124,7 +131,7 @@ class Bridzik():
return status
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):
if len(self.series[-1].get_last_round().stashes) > 1:
@@ -169,7 +176,7 @@ class Series():
self.start_new_round()
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):
return [r.get_points_summary() for r in self.rounds if r.is_completed()]
@@ -191,7 +198,7 @@ class Series():
class Round():
def __init__(self, round_number: int, first_player: int, cards: []=cards, shuffler=shuffle):
# 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.')
if first_player not in [0, 1, 2, 3]:
raise BridzikException('Cislo hraca musi byt 0, 1, 2 alebo 3.')
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 55f456d..538e1c1 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,6 +1,6 @@
services:
db:
- image: postgres:16-alpine
+ image: postgres:18-alpine
environment:
POSTGRES_USER: bridzik
POSTGRES_PASSWORD: bridzik
@@ -8,7 +8,7 @@ services:
ports:
- "5432:5432"
volumes:
- - pgdata:/var/lib/postgresql/data
+ - pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bridzik -d bridzik"]
interval: 5s
diff --git a/frontend/index.html b/frontend/index.html
index 18c456e..ad96af3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,14 @@
- {keys.map((key) => {
- // During guessing phase cards are visible at full opacity (just not clickable).
- // Only dim cards during the play phase when they can't be played.
- const disabled = isPlayPhase && (!myTurn || (playableKeys !== undefined && !playableKeys.has(key)));
- return (
- emit.playCard(key)}
- />
- );
- })}
+
+
+
+ Tvoje karty
+
+
+
+ {desktop ? (
+ // Desktop has room — keep cards grouped by suit, wrap if needed.
+
+ {groups.map(({ color, keys }) => (
+
+ {keys.map((key) => (
+
+ ))}
+
+ ))}
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** 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; 1 ? Math.min(CARD_W + 6, (MAX_ROW - CARD_W) / (n - 1)) : 0;
+ const margin = step - CARD_W; // negative → overlap, positive → gap
+
+ return (
+
+ {playOrder.map((order, i) => {
+ const card = stash.cards[String(order)];
+ // Only render cards that have actually been played — no placeholder slot
+ // for players still to play this trick.
+ if (!card) return null;
+ const offset = (order - myOrder + 4) % 4;
+ return (
+
+
+ {nameFor(order)}
+
+ {/* Outer: flies in from the player's direction. Inner: static rotation. */}
+
+
+
+
+
+
+ );
+ })}
);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 9ecd347..fc64c58 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2,6 +2,71 @@
@tailwind components;
@tailwind utilities;
-body {
- @apply bg-slate-900 text-white min-h-screen;
+@layer base {
+ 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; }
}
diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts
index 6490d23..51bbb8d 100644
--- a/frontend/src/lib/socket.ts
+++ b/frontend/src/lib/socket.ts
@@ -47,6 +47,8 @@ export const emit = {
_pendingGid = 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'),
endGame: (gid: string) => socket.emit('end_game', gid),
startGame: (gid: string) => socket.emit('start_game', gid),
diff --git a/frontend/src/lib/useFitScale.ts b/frontend/src/lib/useFitScale.ts
new file mode 100644
index 0000000..207cb86
--- /dev/null
+++ b/frontend/src/lib/useFitScale.ts
@@ -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(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 };
+}
diff --git a/frontend/src/lib/useIsDesktop.ts b/frontend/src/lib/useIsDesktop.ts
new file mode 100644
index 0000000..d67f0bd
--- /dev/null
+++ b/frontend/src/lib/useIsDesktop.ts
@@ -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;
+}
diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx
index 20945c0..2763b8f 100644
--- a/frontend/src/pages/Auth.tsx
+++ b/frontend/src/pages/Auth.tsx
@@ -2,13 +2,18 @@ import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
+import RulesModal from '../components/RulesModal';
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() {
const [mode, setMode] = useState('login');
const [username, setUsername] = useState(localStorage.getItem('bridzik_name') ?? '');
const [code, setCode] = useState('');
+ const [showRules, setShowRules] = useState(false);
const registration = useGameStore((s) => s.registration);
const setRegistration = useGameStore((s) => s.setRegistration);
@@ -41,28 +46,28 @@ export default function Auth() {
};
return (
-
-
Bridzik
-
- Prihlas sa kodom z aplikacie (napr. Google Authenticator).
+
+
Bridžik
+
+ Prihlás sa kódom z aplikácie (napr. Google Authenticator).
+ );
+
+ // 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) => (
+
+
+ {seats.map((_, c) => (
+
+
0 ? SEP : undefined }}>
+ {sA + 1}
+
+
+ {sB !== undefined ? sB + 1 : ''}
+
+
+ ))}
+
+ );
+
+ // 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 (
+
+ {seriesTags(sA, sB)}
+ {roundNums.map((rn) => roundRow(rn, [sA, sB]))}
+ {sigmaRow([sA, sB])}
+ {bi < blocks.length - 1 && }
+
+ );
+ });
+ })();
+
+ // 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 (
+
+