Compare commits

...
2 Commits
Author SHA1 Message Date
nessi e479e5b2a8 Refine TopBar mobile layout with improved spacing and text truncation
Adjusted TopBar mobile styling to prevent wrapping and optimize space usage on narrow screens. Updated flex properties to prevent button stretching, reduced padding and gaps for tighter layout, and added text truncation for game title with 105px max-width. Decreased minimum button height from 44px to 40px and refined font sizes for better fit on small devices.
2026-08-01 18:29:56 +02:00
nessi a7ac55c598 Add game start functionality with automatic player chip generation
Implemented host-controlled game start mechanism that generates unique player chips from user names. Added `started_at` timestamp to games and new `game_chips` table to store player identifiers. Chips are created using first name initial plus first two surname letters (e.g., SNE for Sascha Nesterovic) with automatic conflict resolution. Updated frontend to display start button for hosts, show game status, and populate chip selection modal
2026-08-01 18:29:02 +02:00
9 changed files with 168 additions and 16 deletions
+4
View File
@@ -8,6 +8,8 @@ A small multiplayer web app that acts as a digital note sheet for a Harry Potter
- Admin-managed user creation and deactivation - Admin-managed user creation and deactivation
- Multiple games per user - Multiple games per user
- Join games using a short join code - Join games using a short join code
- Host-controlled game start with automatic player chips
- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic
- Automatic player list with host indication - Automatic player list with host indication
- Personal note sheet for each player and game - Personal note sheet for each player and game
- Categories for suspects, items, and locations - Categories for suspects, items, and locations
@@ -200,6 +202,8 @@ The setup endpoint is available only while no administrator exists. After the fi
- `GET /games/{game_id}` - `GET /games/{game_id}`
- `GET /games/{game_id}/members` - `GET /games/{game_id}/members`
- `PATCH /games/{game_id}/winner` - `PATCH /games/{game_id}/winner`
- `POST /games/{game_id}/start`
- `GET /games/{game_id}/chips`
- `GET /games/{game_id}/sheet` - `GET /games/{game_id}/sheet`
- `PATCH /games/{game_id}/sheet/{entry_id}` - `PATCH /games/{game_id}/sheet/{entry_id}`
+8
View File
@@ -136,6 +136,14 @@ Very small, pragmatic auto-migration (no alembic).
except Exception: except Exception:
db.rollback() db.rollback()
# started_at: games are open for joining until the host starts them
if not _has_column(db, "games", "started_at"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN started_at DATETIME"))
db.commit()
except Exception:
db.rollback()
# host_user_id (nice to have for "only host can set winner") # host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"): if not _has_column(db, "games", "host_user_id"):
try: try:
+12 -1
View File
@@ -54,6 +54,17 @@ class Game(Base):
code: Mapped[str] = mapped_column(String, unique=True, index=True) code: Mapped[str] = mapped_column(String, unique=True, index=True)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GameChip(Base):
__tablename__ = "game_chips"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_chip_user"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
chip: Mapped[str] = mapped_column(String)
class GameMember(Base): class GameMember(Base):
@@ -86,4 +97,4 @@ class SheetState(Base):
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) note_tag: Mapped[str | None] = mapped_column(String, nullable=True)
chip: Mapped[str | None] = mapped_column(String, nullable=True) chip: Mapped[str | None] = mapped_column(String, nullable=True)
+83 -3
View File
@@ -1,8 +1,12 @@
import hashlib, random import hashlib
import random
import re
import unicodedata
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"]) router = APIRouter(prefix="/games", tags=["games"])
@@ -27,6 +31,25 @@ def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def make_user_chip(user: User) -> str:
"""Create first-name initial + first two surname letters, e.g. SNE."""
display_name = (user.display_name or "").strip()
if not display_name:
display_name = (user.email or "").split("@", 1)[0].replace(".", " ")
parts = re.split(r"\s+", display_name)
first = parts[0] if parts else "X"
last = parts[-1] if len(parts) > 1 else first
def letters(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
return "".join(c for c in normalized if c.isalpha())
first_letters = letters(first).upper() or "X"
last_letters = letters(last).upper() or "X"
return (first_letters[0] + last_letters[:2]).ljust(3, "X")
def ensure_member(db: Session, game_id: str, user_id: str): def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex: if ex:
@@ -78,6 +101,8 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
g = db.query(Game).filter(Game.code == code).first() g = db.query(Game).filter(Game.code == code).first()
if not g: if not g:
raise HTTPException(404, "game not found") raise HTTPException(404, "game not found")
if g.started_at:
raise HTTPException(400, "game already started")
ensure_member(db, g.id, uid) ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
@@ -112,6 +137,7 @@ def list_games(req: Request, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id, "host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"started": bool(g.started_at),
} }
) )
return out return out
@@ -139,9 +165,63 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"winner_display_name": winner_display_name, "winner_display_name": winner_display_name,
"started": bool(g.started_at),
"started_at": g.started_at,
} }
@router.post("/{game_id}/start")
def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if g.host_user_id != uid:
raise HTTPException(403, "only host can start the game")
if not g.started_at:
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
used = set()
for member in members:
base = make_user_chip(member)
chip = base
suffix = 2
while chip in used:
chip = f"{base[:2]}{suffix}"
suffix += 1
used.add(chip)
db.add(GameChip(game_id=game_id, user_id=member.id, chip=chip))
g.started_at = datetime.now(timezone.utc)
db.add(g)
db.commit()
return {"ok": True, "started": True}
@router.get("/{game_id}/chips")
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if not g.started_at:
return []
chips = (
db.query(GameChip, User)
.join(User, User.id == GameChip.user_id)
.filter(GameChip.game_id == game_id)
.order_by(User.display_name.asc(), User.email.asc())
.all()
)
return [{"user_id": chip.user_id, "chip": chip.chip, "display_name": user.display_name, "email": user.email} for chip, user in chips]
@router.get("/{game_id}/members") @router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
@@ -277,4 +357,4 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+21
View File
@@ -50,6 +50,7 @@ export default function App() {
// Game meta // Game meta
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id} const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [gameChips, setGameChips] = useState([]);
// Winner selection (host only) // Winner selection (host only)
const [winnerUserId, setWinnerUserId] = useState(""); const [winnerUserId, setWinnerUserId] = useState("");
@@ -138,6 +139,9 @@ export default function App() {
setGameMeta(meta); setGameMeta(meta);
setWinnerUserId(meta?.winner_user_id || ""); setWinnerUserId(meta?.winner_user_id || "");
const chips = meta?.started ? await api(`/games/${gameId}/chips`) : [];
setGameChips(chips || []);
const mem = await api(`/games/${gameId}/members`); const mem = await api(`/games/${gameId}/members`);
setMembers(mem); setMembers(mem);
@@ -327,6 +331,7 @@ export default function App() {
setSheet(null); setSheet(null);
setGameMeta(null); setGameMeta(null);
setMembers([]); setMembers([]);
setGameChips([]);
setWinnerUserId(""); setWinnerUserId("");
// reset winner celebration on logout // reset winner celebration on logout
@@ -431,6 +436,7 @@ export default function App() {
setSheet(null); setSheet(null);
setGameMeta(null); setGameMeta(null);
setMembers([]); setMembers([]);
setGameChips([]);
setWinnerUserId(""); setWinnerUserId("");
setPulseId(null); setPulseId(null);
@@ -469,6 +475,12 @@ export default function App() {
setGameId(res.id); setGameId(res.id);
}; };
const startGame = async () => {
if (!gameId || !gameMeta?.host_user_id || me?.id !== gameMeta.host_user_id) return;
await api(`/games/${gameId}/start`, { method: "POST" });
await loadGameMeta();
};
// ===== Winner ===== // ===== Winner =====
const saveWinner = async () => { const saveWinner = async () => {
if (!gameId) return; if (!gameId) return;
@@ -501,6 +513,10 @@ export default function App() {
const next = cycleTag(entry.note_tag); const next = cycleTag(entry.note_tag);
if (next === "s") { if (next === "s") {
if (!gameMeta?.started || gameChips.length === 0) {
showSnack("Das Spiel muss zuerst gestartet werden.");
return;
}
setChipEntry(entry); setChipEntry(entry);
setChipOpen(true); setChipOpen(true);
return; return;
@@ -642,6 +658,10 @@ export default function App() {
members={members} members={members}
me={me} me={me}
hostUserId={gameMeta?.host_user_id || ""} hostUserId={gameMeta?.host_user_id || ""}
isHost={isHost}
started={!!gameMeta?.started}
chipCount={gameChips.length}
onStartGame={startGame}
/> />
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
@@ -717,6 +737,7 @@ export default function App() {
chipOpen={chipOpen} chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash} closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip} chooseChip={chooseChip}
chips={gameChips}
/> />
<StatsModal <StatsModal
+10 -5
View File
@@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
@@ -19,13 +18,19 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{CHIP_LIST.map((c) => ( {chips.map((item) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}> <button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}>
{c} {item.chip}
</button> </button>
))} ))}
</div> </div>
{!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}>
Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden.
</div>
)}
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}> <div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf . Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div> </div>
@@ -10,6 +10,10 @@ export default function GamePickerCard({
members = [], members = [],
me, me,
hostUserId, hostUserId,
isHost = false,
started = false,
chipCount = 0,
onStartGame,
}) { }) {
const cur = games.find((x) => x.id === gameId); const cur = games.find((x) => x.id === gameId);
@@ -85,6 +89,24 @@ export default function GamePickerCard({
</div> </div>
)} )}
<div style={{ padding: "0 12px 12px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap" }}>
{!started ? (
isHost ? (
<button onClick={onStartGame} style={styles.primaryBtn} disabled={!members.length}>
Spiel starten
</button>
) : (
<div style={{ fontSize: 12, color: stylesTokens.textDim }}>
Warte auf den Host, der das Spiel startet.
</div>
)
) : (
<div style={{ fontSize: 12, color: stylesTokens.textDim }}>
Spiel läuft · {chipCount} Spieler-Chips erstellt
</div>
)}
</div>
{/* Spieler */} {/* Spieler */}
{members?.length > 0 && ( {members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}> <div style={{ padding: "0 12px 12px" }}>
-1
View File
@@ -1,2 +1 @@
export const API_BASE = "/api"; export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
@@ -98,11 +98,12 @@ export function useHpGlobalStyles() {
.hp-admin-status--disabled { color: #ffb3b3 !important; } .hp-admin-status--disabled { color: #ffb3b3 !important; }
@media (max-width: 600px) { @media (max-width: 600px) {
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; } .hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
.hp-topbar { align-items: flex-start !important; padding: 12px !important; flex-wrap: wrap !important; } .hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
.hp-topbar-actions { width: 100%; margin-left: 0; justify-content: stretch; } .hp-topbar > div:first-child { min-width: 0; flex: 1; }
.hp-user-menu-wrap { flex: 1; min-width: 0; } .hp-topbar > div:first-child > div:last-child { max-width: 105px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px !important; }
.hp-topbar-user, .hp-topbar-new { flex: 1; min-height: 44px; } .hp-topbar-actions { width: auto; flex: 0 0 auto; margin-left: 0; gap: 5px !important; }
.hp-topbar-user { width: 100%; } .hp-user-menu-wrap { flex: 0 0 auto; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 0 0 auto; min-height: 40px; width: auto; padding: 8px 9px !important; font-size: 13px; }
.hp-topbar-user { justify-content: center; } .hp-topbar-user { justify-content: center; }
.hp-user-dropdown { left: 0 !important; right: auto !important; width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; } .hp-user-dropdown { left: 0 !important; right: auto !important; width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -120,7 +121,8 @@ export function useHpGlobalStyles() {
} }
@media (max-width: 360px) { @media (max-width: 360px) {
.hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; } .hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; }
.hp-topbar { gap: 8px !important; } .hp-topbar { gap: 5px !important; }
.hp-topbar-user, .hp-topbar-new { padding-left: 7px !important; padding-right: 7px !important; font-size: 12px; }
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);