Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ae869ee8 | ||
|
|
04185a0d97 | ||
|
|
97c71cf6e1 |
@@ -253,8 +253,52 @@ def public_games() -> list:
|
||||
|
||||
# --- emit helpers ---------------------------------------------------------
|
||||
|
||||
def _online_player_ids() -> set[int]:
|
||||
"""Distinct logged-in players currently connected.
|
||||
|
||||
Counting *players* (by account id), not raw sockets, is what makes the
|
||||
lobby counters stable and meaningful:
|
||||
* one person with several tabs/devices counts once;
|
||||
* a not-yet-logged-in visitor (still on the auth screen) is not a player
|
||||
and is not counted;
|
||||
* reconnect churn and phantom sockets lingering until the engine.io ping
|
||||
timeout no longer inflate the number, because the identity is counted,
|
||||
not the transient connection.
|
||||
"""
|
||||
return {acc["player_id"] for acc in accounts.values()}
|
||||
|
||||
|
||||
def _in_game_player_ids() -> set[int]:
|
||||
"""Players who currently occupy a seat in a game (any of their sockets is
|
||||
seated) -- 'busy', i.e. not available to start/join another game."""
|
||||
ids: set[int] = set()
|
||||
for sid in sessions:
|
||||
acc = accounts.get(sid)
|
||||
if acc is not None:
|
||||
ids.add(acc["player_id"])
|
||||
return ids
|
||||
|
||||
|
||||
def online_count() -> int:
|
||||
"""Number of distinct logged-in players online (in the lobby or in a game)."""
|
||||
return len(_online_player_ids())
|
||||
|
||||
|
||||
def available_count() -> int:
|
||||
"""Online players not currently seated in any game (free to join/create)."""
|
||||
return len(_online_player_ids() - _in_game_player_ids())
|
||||
|
||||
|
||||
async def broadcast_lobby():
|
||||
await sio.emit("get_games", {"games": public_games()}, room=LOBBY)
|
||||
await sio.emit(
|
||||
"get_games",
|
||||
{
|
||||
"games": public_games(),
|
||||
"online_count": online_count(),
|
||||
"available_count": available_count(),
|
||||
},
|
||||
room=LOBBY,
|
||||
)
|
||||
|
||||
|
||||
async def send_game_status(gid: str):
|
||||
@@ -455,6 +499,13 @@ async def send_error_room(gid: str, message: str):
|
||||
await sio.emit("error", {"error": message}, room=gid)
|
||||
|
||||
|
||||
def _public_identity(account: dict) -> dict:
|
||||
"""The client only needs `player_id` to compare against game rosters (see
|
||||
`isMember` in frontend/src/pages/GameList.tsx) -- `username` stays purely
|
||||
server-side (used to name the seat on register_player/rejoin_game etc.)."""
|
||||
return {"player_id": account["player_id"]}
|
||||
|
||||
|
||||
# --- connection lifecycle -------------------------------------------------
|
||||
|
||||
@sio.event
|
||||
@@ -465,8 +516,9 @@ async def connect(sid, environ, auth=None):
|
||||
identity = await auth_module.player_by_token(token)
|
||||
if identity is not None:
|
||||
accounts[sid] = identity
|
||||
await sio.emit("login", {"player": identity}, to=sid)
|
||||
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
||||
await sio.emit("login", {"player": _public_identity(identity)}, to=sid)
|
||||
# Broadcast (not just emit to sid) so everyone's online-players count stays live.
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.event
|
||||
@@ -478,6 +530,7 @@ async def disconnect(sid):
|
||||
player = game.player_by_sid(sid)
|
||||
if player is not None:
|
||||
await _mark_player_offline(game, player)
|
||||
# accounts/sessions already updated above, so the lobby counters are correct.
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@@ -513,7 +566,11 @@ async def confirm_account(sid, username, code):
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await _record_login_event(sid, identity["player_id"])
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
await sio.emit(
|
||||
"login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid
|
||||
)
|
||||
# A new player just came online -> refresh everyone's lobby counters.
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("login")
|
||||
@@ -533,7 +590,28 @@ async def login(sid, username, code):
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await _record_login_event(sid, identity["player_id"])
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
await sio.emit(
|
||||
"login", {"player": _public_identity(accounts[sid]), "token": identity["token"]}, to=sid
|
||||
)
|
||||
# A new player just came online -> refresh everyone's lobby counters.
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
@sio.on("logout")
|
||||
async def logout(sid):
|
||||
"""De-authenticate this connection without touching the transport.
|
||||
|
||||
The previous approach (client calling `socket.disconnect()` then
|
||||
immediately `socket.connect()`) was racy: python-socketio's disconnect
|
||||
isn't instantaneous (the old sid can briefly linger past its ping-timeout
|
||||
while a brand new sid is already connecting), so for that window BOTH
|
||||
sids were counted in the lobby online/available totals -- exactly the
|
||||
transient over-count reported. Simply dropping the account association
|
||||
server-side avoids any connect/disconnect churn entirely.
|
||||
"""
|
||||
accounts.pop(sid, None)
|
||||
# A player just went offline -> refresh everyone's lobby counters.
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
# --- lobby ----------------------------------------------------------------
|
||||
@@ -550,7 +628,15 @@ async def create_game(sid, name):
|
||||
|
||||
@sio.on("get_games")
|
||||
async def get_games(sid, *args):
|
||||
await sio.emit("get_games", {"games": public_games()}, to=sid)
|
||||
await sio.emit(
|
||||
"get_games",
|
||||
{
|
||||
"games": public_games(),
|
||||
"online_count": online_count(),
|
||||
"available_count": available_count(),
|
||||
},
|
||||
to=sid,
|
||||
)
|
||||
|
||||
|
||||
@sio.on("register_player")
|
||||
@@ -800,7 +886,15 @@ async def restore_game(sid, gid):
|
||||
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)
|
||||
return await sio.emit(
|
||||
"get_games",
|
||||
{
|
||||
"games": public_games(),
|
||||
"online_count": online_count(),
|
||||
"available_count": available_count(),
|
||||
},
|
||||
to=sid,
|
||||
)
|
||||
|
||||
info = await history.reopen_game(gid, account["player_id"])
|
||||
if info is None:
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,23 @@
|
||||
import { useCardSkin } from '../lib/cardSkin';
|
||||
|
||||
/** Small header button that flips the deck between the classic vector cards and
|
||||
* the illustrated "sedmové" art. Persists via the cardSkin store. */
|
||||
export default function CardSkinToggle({ className = '' }: { className?: string }) {
|
||||
const skin = useCardSkin((s) => s.skin);
|
||||
const toggle = useCardSkin((s) => s.toggle);
|
||||
const illustrated = skin === 'illustrated';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title="Prepnúť vzhľad kariet"
|
||||
aria-label="Prepnúť vzhľad kariet"
|
||||
aria-pressed={illustrated}
|
||||
className={`text-[13px] whitespace-nowrap transition-colors ${
|
||||
illustrated ? 'text-gold hover:text-gold-bright' : 'text-[#7a7058] hover:text-gold'
|
||||
} ${className}`}
|
||||
>
|
||||
{illustrated ? '🂠 Sedmové' : '🂠 Klasické'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Card } from '../types';
|
||||
import { useCardSkin, suitIcon } from '../lib/cardSkin';
|
||||
|
||||
const SUIT_SYMBOL: Record<string, string> = {
|
||||
HEARTS: '♥',
|
||||
@@ -19,6 +20,15 @@ const VALUE_LABEL: Record<string, string> = {
|
||||
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
|
||||
};
|
||||
|
||||
// The bell, leaf and heart icons are a touch taller than the acorn, so in the
|
||||
// centre they sometimes bleed into the corner marks — nudge those three down.
|
||||
const CENTER_ICON_SCALE: Record<string, number> = {
|
||||
HEARTS: 0.85,
|
||||
LEAVES: 0.85,
|
||||
BELLS: 0.85,
|
||||
ACORNS: 1,
|
||||
};
|
||||
|
||||
// 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 },
|
||||
@@ -37,26 +47,45 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
|
||||
const skin = useCardSkin((s) => s.skin);
|
||||
const symbol = SUIT_SYMBOL[card.color];
|
||||
const color = SUIT_COLOR[card.color];
|
||||
const label = VALUE_LABEL[card.value];
|
||||
const d = DIMS[size];
|
||||
|
||||
const interactive = !disabled && !!onClick;
|
||||
// 'illustrated' skin keeps the classic card but swaps the ♥♠♣♦ glyphs for the
|
||||
// suit-icon artwork; 'classic' uses the plain text glyphs.
|
||||
const useIcons = skin === 'illustrated';
|
||||
// With the icon artwork the bell suit reads brown, so tint its value label to
|
||||
// match instead of the classic blue.
|
||||
const labelColor = useIcons && card.color === 'BELLS' ? '#8a5a1c' : color;
|
||||
|
||||
const corner = (rotated: boolean) => (
|
||||
<span
|
||||
className="absolute flex flex-col items-center leading-none"
|
||||
// Icons align to the number's left edge so a wide label (VIII) doesn't shove
|
||||
// the centred icon toward the middle; classic text stays centred as before.
|
||||
className={`absolute flex flex-col leading-none ${useIcons ? 'items-start' : 'items-center'}`}
|
||||
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 }}>
|
||||
<span style={{ color: labelColor, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
||||
{label}
|
||||
</span>
|
||||
{useIcons ? (
|
||||
<img
|
||||
src={suitIcon(card.color, 'small')}
|
||||
alt={symbol}
|
||||
draggable={false}
|
||||
style={{ height: d.suitSm * 1.25, width: 'auto', marginTop: 1 }}
|
||||
className="select-none"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -76,7 +105,17 @@ export default function CardView({ card, onClick, disabled = false, highlight =
|
||||
>
|
||||
{corner(false)}
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
{useIcons ? (
|
||||
<img
|
||||
src={suitIcon(card.color, 'large')}
|
||||
alt={symbol}
|
||||
draggable={false}
|
||||
style={{ height: d.suitLg * CENTER_ICON_SCALE[card.color], width: 'auto' }}
|
||||
className="select-none"
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
|
||||
)}
|
||||
</span>
|
||||
{corner(true)}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { emit } from '../lib/socket';
|
||||
import { forbiddenGuess } from '../lib/gameRules';
|
||||
import { GUESS_BUTTON } from '../lib/boardTheme';
|
||||
|
||||
interface Props {
|
||||
cardsInRound: number;
|
||||
@@ -7,15 +8,16 @@ interface Props {
|
||||
myOrder: number;
|
||||
activePlayer: number;
|
||||
activePlayerName: string;
|
||||
desktop?: boolean;
|
||||
}
|
||||
|
||||
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
|
||||
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName, desktop = true }: Props) {
|
||||
const isMyTurn = activePlayer === myOrder;
|
||||
const forbidden = forbiddenGuess(cardsInRound, guesses);
|
||||
|
||||
if (!isMyTurn) {
|
||||
return (
|
||||
<p className="text-center text-sm text-green-dim py-3">
|
||||
<p className="text-center text-[14px] text-green-dim py-3">
|
||||
Čaká sa na tip:{' '}
|
||||
<span className="font-serif text-gold">{activePlayerName}</span>
|
||||
</p>
|
||||
@@ -23,6 +25,7 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
|
||||
}
|
||||
|
||||
const options = Array.from({ length: cardsInRound + 1 }, (_, i) => i);
|
||||
const btn = desktop ? GUESS_BUTTON.desktop : GUESS_BUTTON.mobile;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-3">
|
||||
@@ -36,8 +39,9 @@ export default function GuessControls({ cardsInRound, guesses, myOrder, activePl
|
||||
disabled={isForbidden}
|
||||
onClick={() => emit.addGuess(n)}
|
||||
title={isForbidden ? 'Zakázaná hodnota (súčet = počet kopiek)' : undefined}
|
||||
style={{ width: btn.size, height: btn.size, fontSize: btn.font }}
|
||||
className={[
|
||||
'w-11 h-11 rounded-full font-serif text-lg border-2 transition-colors',
|
||||
'rounded-full font-serif border-2 transition-colors',
|
||||
isForbidden
|
||||
? 'border-[#5a2a2a] text-[#7a4040] opacity-40 cursor-not-allowed'
|
||||
: 'border-gold/50 text-gold hover:bg-gold hover:text-table hover:border-gold active:scale-95',
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
username?: string;
|
||||
onlineCount?: number;
|
||||
availableCount?: number;
|
||||
onHistory: () => void;
|
||||
onDonate: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/** Header navigation: a row of links from `sm:` up, a hamburger dropdown below it. */
|
||||
export default function HeaderMenu({ username, onHistory, onDonate, onLogout }: Props) {
|
||||
export default function HeaderMenu({
|
||||
onlineCount,
|
||||
availableCount,
|
||||
onHistory,
|
||||
onDonate,
|
||||
onLogout,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -34,9 +41,16 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:flex items-center gap-3 text-sm">
|
||||
<span className="text-green-dim">{username}</span>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{typeof onlineCount === 'number' && (
|
||||
<span className="flex items-center gap-1.5 text-green-dim" title="Online / dostupní hráči">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-score" />
|
||||
{onlineCount}
|
||||
{typeof availableCount === 'number' && `/${availableCount} free`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<button onClick={onHistory} className="text-gold hover:text-gold-bright">
|
||||
História
|
||||
</button>
|
||||
@@ -66,11 +80,6 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
||||
role="menu"
|
||||
className="absolute right-0 top-11 z-40 w-44 bg-header border border-[#142018] rounded-xl py-1 shadow-[0_20px_60px_rgba(0,0,0,.6)]"
|
||||
>
|
||||
{username && (
|
||||
<p className="px-4 py-2 text-xs text-green-dim border-b border-[#142018] truncate">
|
||||
{username}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={pick(onHistory)}
|
||||
@@ -95,6 +104,6 @@ export default function HeaderMenu({ username, onHistory, onDonate, onLogout }:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ interface Props {
|
||||
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);
|
||||
// Bid status at a glance: terracotta once the player has taken more tricks
|
||||
// than bid (the bid is already lost), gold while exactly on target, cream
|
||||
// while still chasing. Scoring: exact match = 10 + bid, anything else = 0.
|
||||
const guessColor =
|
||||
guess === null ? '#d8cba6' : won > guess ? '#e08066' : won === guess ? '#e8c14a' : '#d8cba6';
|
||||
// Oval: height a touch shorter than size so it reads as an ellipse. Width
|
||||
// starts at `size` (a circle for short names) but grows with the name via
|
||||
// fit-content + padding, up to a cap beyond which the name is ellipsised
|
||||
@@ -58,7 +63,10 @@ export default function PlayerCircle({ name, won, guess, active, size = 52 }: Pr
|
||||
}}
|
||||
>
|
||||
{won}
|
||||
<span style={{ fontSize: valueFont * 0.6, color: '#b0a585' }}>/{guess ?? '?'}</span>
|
||||
{/* The bid matters as much as the tricks won — keep it nearly as big
|
||||
and in readable cream; only the slash separator stays muted. */}
|
||||
<span style={{ fontSize: valueFont * 0.7, color: '#b0a585' }}>/</span>
|
||||
<span style={{ fontSize: valueFont , color: guessColor }}>{guess ?? '?'}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import type { PlayerInfo } from '../types';
|
||||
import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
import { STANDINGS_FONT } from '../lib/boardTheme';
|
||||
|
||||
interface Props {
|
||||
standings: number[][][];
|
||||
@@ -40,14 +41,8 @@ export default function Standings({ standings, guesses = [], players, myOrder, d
|
||||
const ROUNDS_PER_SERIES = 8;
|
||||
|
||||
// Bigger, more legible type on the wide desktop sidebar; compact on mobile.
|
||||
const fz = {
|
||||
head: desktop ? 11 : 10,
|
||||
idx: desktop ? 13 : 9,
|
||||
cell: desktop ? 19 : 14,
|
||||
dot: desktop ? 18 : 13,
|
||||
sigma: desktop ? 13 : 9,
|
||||
total: desktop ? 20 : 18,
|
||||
};
|
||||
// Sizes live in one place (boardTheme.ts) alongside the rest of the board.
|
||||
const fz = desktop ? STANDINGS_FONT.desktop : STANDINGS_FONT.mobile;
|
||||
|
||||
const gridCols = { gridTemplateColumns: `28px repeat(${cols.length}, 1fr)` };
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Single source of truth for the game board's fixed pixel sizes.
|
||||
//
|
||||
// The board deliberately uses fixed px (not rem) so it stays pixel-precise and
|
||||
// scales as one unit via `useFitScale` (desktop) — see index.css for why the
|
||||
// board opts out of the global rem type lever. These constants keep those px
|
||||
// values in one place instead of scattered as inline literals across the board
|
||||
// components (Standings / GameTable / …).
|
||||
//
|
||||
// PlayerCircle derives its own font/padding as ratios of the `size` it gets, so
|
||||
// its internal ratios stay local to that component; only the per-seat `size`
|
||||
// inputs live here.
|
||||
|
||||
/** Player-circle diameter per seat, by viewport. */
|
||||
export const SEAT_SIZE = {
|
||||
desktop: { top: 64, side: 64, me: 70 },
|
||||
mobile: { top: 52, side: 48, me: 60 },
|
||||
} as const;
|
||||
|
||||
/** Score list (Standings) type sizes. */
|
||||
export const STANDINGS_FONT = {
|
||||
desktop: { head: 12, idx: 13, cell: 18, dot: 18, sigma: 18, total: 20 },
|
||||
mobile: { head: 11, idx: 9, cell: 16, dot: 13, sigma: 15, total: 18 },
|
||||
} as const;
|
||||
|
||||
/** Header / score totals row (GameTable). The value size differs between the
|
||||
* compact desktop header and the mobile header. */
|
||||
export const TOTALS_FONT = {
|
||||
label: 12,
|
||||
valueDesktop: 18,
|
||||
valueMobile: 17,
|
||||
} as const;
|
||||
|
||||
/** Guess (tip) picker buttons (GuessControls): round button diameter + label
|
||||
* size, in px — kept fixed like the rest of the board (not rem Tailwind classes)
|
||||
* so they don't drift with the global font-size lever. */
|
||||
export const GUESS_BUTTON = {
|
||||
desktop: { size: 48, font: 22 },
|
||||
mobile: { size: 40, font: 18 },
|
||||
} as const;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CardColor } from '../types';
|
||||
|
||||
// Two ways to draw the suit marks on the (otherwise identical) cards: plain
|
||||
// ♥♠♣♦ text glyphs ('classic') or the "sedmové" suit-icon artwork ('illustrated').
|
||||
export type CardSkin = 'classic' | 'illustrated';
|
||||
|
||||
const STORAGE_KEY = 'bridzik.cardSkin';
|
||||
|
||||
function loadSkin(): CardSkin {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'illustrated' ? 'illustrated' : 'classic';
|
||||
} catch {
|
||||
return 'classic';
|
||||
}
|
||||
}
|
||||
|
||||
interface CardSkinStore {
|
||||
skin: CardSkin;
|
||||
setSkin: (skin: CardSkin) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useCardSkin = create<CardSkinStore>((set) => ({
|
||||
skin: loadSkin(),
|
||||
setSkin: (skin) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, skin);
|
||||
} catch {
|
||||
/* ignore — a private-mode failure just means the choice isn't persisted */
|
||||
}
|
||||
set({ skin });
|
||||
},
|
||||
toggle: () => set((s) => {
|
||||
const skin: CardSkin = s.skin === 'illustrated' ? 'classic' : 'illustrated';
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, skin);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { skin };
|
||||
}),
|
||||
}));
|
||||
|
||||
// Filenames in /public/cards use their own suit/value words, not the engine's
|
||||
// enum names — map between the two here.
|
||||
const SUIT_FILE: Record<CardColor, string> = {
|
||||
HEARTS: 'heart',
|
||||
LEAVES: 'leaf',
|
||||
ACORNS: 'acorn',
|
||||
BELLS: 'bell',
|
||||
};
|
||||
|
||||
/** Path to a suit icon at a given resolution, e.g. `/cards/suit-icons/heart-icon@small.png`.
|
||||
* Used to render the suit marks on the classic vector cards. */
|
||||
export function suitIcon(color: CardColor, size: 'small' | 'medium' | 'large'): string {
|
||||
return `/cards/suit-icons/${SUIT_FILE[color]}-icon@${size}.png`;
|
||||
}
|
||||
@@ -3,11 +3,11 @@ import { useGameStore } from '../store/gameStore';
|
||||
import type {
|
||||
Account,
|
||||
GameDetail,
|
||||
GameInfo,
|
||||
GameStatusPayload,
|
||||
Hand,
|
||||
HistoryGame,
|
||||
MyPlayer,
|
||||
OnlineCountPayload,
|
||||
Registration,
|
||||
} from '../types';
|
||||
|
||||
@@ -29,6 +29,9 @@ export const emit = {
|
||||
confirmAccount: (username: string, code: string) =>
|
||||
socket.emit('confirm_account', username, code),
|
||||
login: (username: string, code: string) => socket.emit('login', username, code),
|
||||
// De-authenticates this connection server-side (no disconnect/reconnect --
|
||||
// see the "logout" handler comment in api/__init__.py for why).
|
||||
logout: () => socket.emit('logout'),
|
||||
// history
|
||||
getPlayerHistory: () => socket.emit('get_player_history'),
|
||||
getGameDetail: (gid: string) => socket.emit('get_game_detail', gid),
|
||||
@@ -64,8 +67,11 @@ export const emit = {
|
||||
};
|
||||
|
||||
export function setupSocketListeners() {
|
||||
socket.on('get_games', ({ games }: { games: GameInfo[] }) => {
|
||||
socket.on('get_games', ({ games, online_count, available_count }: OnlineCountPayload) => {
|
||||
useGameStore.getState().setGames(games);
|
||||
if (typeof online_count === 'number') useGameStore.getState().setOnlineCount(online_count);
|
||||
if (typeof available_count === 'number')
|
||||
useGameStore.getState().setAvailableCount(available_count);
|
||||
});
|
||||
|
||||
// Registration step 1: server returns the otpauth URI to render as a QR code.
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function Auth() {
|
||||
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
||||
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
||||
<p className="text-center text-green-dim text-sm mb-7">
|
||||
Prihlás sa kódom z aplikácie (napr. Google Authenticator).
|
||||
Prihlás sa kódom z aplikácie (<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" className="text-gold underline" target="_blank" rel="noopener noreferrer">napr. Google Authenticator </a>).
|
||||
</p>
|
||||
|
||||
<div className="flex mb-6 rounded-xl overflow-hidden border border-gold/20">
|
||||
@@ -141,7 +141,7 @@ export default function Auth() {
|
||||
{mode === 'register' && registration && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-green-score">
|
||||
Naskenuj QR kód do autentifikačnej aplikácie a opíš aktuálny kód.
|
||||
Naskenuj QR kód do <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" className="text-gold underline" target="_blank" rel="noopener noreferrer">autentifikačnej aplikácie</a> a opíš aktuálny kód.
|
||||
</p>
|
||||
<div className="bg-white rounded-xl p-4 flex justify-center">
|
||||
<QRCodeSVG value={registration.otpauth_uri} size={176} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit, socket, setAuthToken } from '../lib/socket';
|
||||
import { emit, setAuthToken } from '../lib/socket';
|
||||
import { trackEvent } from '../lib/track';
|
||||
import HeaderMenu from '../components/HeaderMenu';
|
||||
import NameModal from '../components/NameModal';
|
||||
@@ -10,6 +10,8 @@ import RulesModal from '../components/RulesModal';
|
||||
export default function GameList() {
|
||||
const navigate = useNavigate();
|
||||
const games = useGameStore((s) => s.games);
|
||||
const onlineCount = useGameStore((s) => s.onlineCount);
|
||||
const availableCount = useGameStore((s) => Math.max(0, s.availableCount - 1));
|
||||
const account = useGameStore((s) => s.account);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
@@ -20,13 +22,13 @@ export default function GameList() {
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
// Tell the server first (while the socket auth is still attached), then
|
||||
// clear local state -- no disconnect/reconnect needed (see emit.logout).
|
||||
emit.logout();
|
||||
localStorage.removeItem('bridzik_token');
|
||||
localStorage.removeItem('bridzik_player');
|
||||
setAuthToken(null);
|
||||
useGameStore.getState().logout();
|
||||
// Drop the authenticated server-side session for this connection.
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
navigate('/auth', { replace: true });
|
||||
};
|
||||
|
||||
@@ -35,7 +37,8 @@ export default function GameList() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="font-serif text-2xl text-gold tracking-wide">Bridžik</h1>
|
||||
<HeaderMenu
|
||||
username={account?.username}
|
||||
onlineCount={onlineCount}
|
||||
availableCount={availableCount}
|
||||
onHistory={() => navigate('/history')}
|
||||
onDonate={() => navigate('/donate')}
|
||||
onLogout={handleLogout}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { computeTotal } from '../lib/standings';
|
||||
import { displayName } from '../lib/names';
|
||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||
import { useFitScale } from '../lib/useFitScale';
|
||||
import { SEAT_SIZE, TOTALS_FONT } from '../lib/boardTheme';
|
||||
import Hand from '../components/Hand';
|
||||
import GuessControls from '../components/GuessControls';
|
||||
import Trick from '../components/Trick';
|
||||
import Standings from '../components/Standings';
|
||||
import PlayerCircle from '../components/PlayerCircle';
|
||||
import FaceDownCards from '../components/FaceDownCards';
|
||||
import CardSkinToggle from '../components/CardSkinToggle';
|
||||
import GameOver from './GameOver';
|
||||
import type { Hand as HandCards, PlayerInfo, StashData } from '../types';
|
||||
|
||||
@@ -205,19 +207,19 @@ export default function GameTable() {
|
||||
<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 }}>
|
||||
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: TOTALS_FONT.label }}>
|
||||
{displayName(p.name)}
|
||||
</div>
|
||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||
{computeTotal(standings, p.order)}
|
||||
</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 }}>
|
||||
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: TOTALS_FONT.label }}>
|
||||
{myPlayer.name}
|
||||
</div>
|
||||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
||||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||
{computeTotal(standings, myOrder)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,27 +243,30 @@ export default function GameTable() {
|
||||
myOrder={myOrder}
|
||||
activePlayer={active_player}
|
||||
activePlayerName={activePlayerName}
|
||||
desktop={desktop}
|
||||
/>
|
||||
) : null
|
||||
);
|
||||
|
||||
const seatSize = desktop ? SEAT_SIZE.desktop : SEAT_SIZE.mobile;
|
||||
|
||||
const topSeat = (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
|
||||
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={seatSize.top} />
|
||||
<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={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
|
||||
<PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={seatSize.side} />
|
||||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meSeat = (
|
||||
<div className="flex justify-center">
|
||||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
|
||||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={seatSize.me} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -283,22 +288,23 @@ export default function GameTable() {
|
||||
<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">
|
||||
<span className="font-serif uppercase tracking-[.14em] text-[18px] 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">
|
||||
<span className="font-serif text-[14px] 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]" />
|
||||
<CardSkinToggle />
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||
<button onClick={handleEnd} className="text-[13px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||||
<button onClick={handleLeave} className="text-[13px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||||
Odísť
|
||||
</button>
|
||||
</div>
|
||||
@@ -344,16 +350,17 @@ export default function GameTable() {
|
||||
{/* 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">
|
||||
<span className="font-serif uppercase tracking-[.1em] text-[12px] text-gold">
|
||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardSkinToggle />
|
||||
{canEnd && (
|
||||
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
|
||||
<button onClick={handleEnd} className="text-[13px] text-[#6a3030] hover:text-red-400">
|
||||
Ukončiť
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
|
||||
<button onClick={handleLeave} className="text-[13px] text-[#7a7058] hover:text-gold">
|
||||
Odísť
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
|
||||
interface GameStore {
|
||||
games: GameInfo[];
|
||||
onlineCount: number;
|
||||
availableCount: number;
|
||||
account: Account | null;
|
||||
registration: Registration | null;
|
||||
history: HistoryGame[];
|
||||
@@ -22,6 +24,8 @@ interface GameStore {
|
||||
error: string | null;
|
||||
|
||||
setGames: (games: GameInfo[]) => void;
|
||||
setOnlineCount: (count: number) => void;
|
||||
setAvailableCount: (count: number) => void;
|
||||
setAccount: (account: Account | null) => void;
|
||||
setRegistration: (registration: Registration | null) => void;
|
||||
setHistory: (history: HistoryGame[]) => void;
|
||||
@@ -38,6 +42,8 @@ interface GameStore {
|
||||
|
||||
export const useGameStore = create<GameStore>((set) => ({
|
||||
games: [],
|
||||
onlineCount: 0,
|
||||
availableCount: 0,
|
||||
account: null,
|
||||
registration: null,
|
||||
history: [],
|
||||
@@ -48,6 +54,8 @@ export const useGameStore = create<GameStore>((set) => ({
|
||||
error: null,
|
||||
|
||||
setGames: (games) => set({ games }),
|
||||
setOnlineCount: (onlineCount) => set({ onlineCount }),
|
||||
setAvailableCount: (availableCount) => set({ availableCount }),
|
||||
setAccount: (account) => set({ account }),
|
||||
setRegistration: (registration) => set({ registration }),
|
||||
setHistory: (history) => set({ history }),
|
||||
|
||||
@@ -55,13 +55,18 @@ export interface GameInfo {
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
export interface OnlineCountPayload {
|
||||
games: GameInfo[];
|
||||
online_count: number;
|
||||
available_count: number;
|
||||
}
|
||||
|
||||
export type Hand = Record<string, Card>;
|
||||
|
||||
// --- authentication (TOTP) ---
|
||||
|
||||
export interface Account {
|
||||
player_id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
|
||||