From d0f65b856e816566fd8f8dc8aca713228326f133 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 11:08:41 +0100 Subject: [PATCH 01/25] Integrate join codes, player management, and themes This update introduces "join codes" for games to simplify game joining. Enhancements include player role and winner management for better organization. Additionally, theme preferences are now user-configurable and persisted server-side. --- backend/app/main.py | 90 +++++++++++- backend/app/models.py | 33 ++++- backend/app/routes/auth.py | 44 +++++- backend/app/routes/games.py | 163 ++++++++++++++++++--- frontend/src/App.jsx | 148 ++++++++++++------- frontend/src/components/GamePickerCard.jsx | 21 ++- frontend/src/components/JoinGameModal.jsx | 74 ++++++++++ frontend/src/components/TopBar.jsx | 26 +++- frontend/src/components/WinnerBadge.jsx | 54 +++---- frontend/src/components/WinnerCard.jsx | 58 +++++--- 10 files changed, 564 insertions(+), 147 deletions(-) create mode 100644 frontend/src/components/JoinGameModal.jsx diff --git a/backend/app/main.py b/backend/app/main.py index fc093c2..1510220 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,14 @@ import os +import random +import string + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from sqlalchemy.orm import Session + from .db import Base, engine, SessionLocal -from .models import User, Entry, Category, Role +from .models import User, Entry, Category, Role, Game, GameMember from .security import hash_password from .routes.auth import router as auth_router from .routes.admin import router as admin_router @@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet") # Intern: Frontend läuft auf :8081 app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"], + allow_origins=[ + "http://localhost:8081", + "http://127.0.0.1:8081", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -24,9 +32,71 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(games_router) +def _rand_join_code(n: int = 6) -> str: + # digits only (kahoot style) + return "".join(random.choice(string.digits) for _ in range(n)) + +def _auto_migrate(db: Session): + """ +Very small, pragmatic auto-migration (no alembic). +- creates missing tables via create_all +- adds missing columns via ALTER TABLE (best effort) + """ + # Users.theme_key + try: + db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'")) + db.commit() + except Exception: + db.rollback() + + # Games.join_code + winner_user_id + try: + db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR")) + db.commit() + except Exception: + db.rollback() + + try: + db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # SheetState.chip_code + try: + db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # Ensure unique index for join_code (best effort) + try: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)")) + db.commit() + except Exception: + db.rollback() + + # Backfill join_code for existing games + games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711 + if games: + used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]]) + for g in games: + code = _rand_join_code() + while code in used: + code = _rand_join_code() + g.join_code = code + used.add(code) + db.commit() + + # Backfill membership: ensure owner is member + all_games = db.query(Game).all() + for g in all_games: + exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first() + if not exists: + db.add(GameMember(game_id=g.id, user_id=g.owner_user_id)) + db.commit() + def seed_entries(db: Session): - # Du kannst hier deine HP-Edition Einträge reinschreiben. - # (Für rein private Nutzung ok – öffentlich würde ich’s generisch machen.) if db.query(Entry).count() > 0: return suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"] @@ -46,14 +116,24 @@ def ensure_admin(db: Session): admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") u = db.query(User).filter(User.email == admin_email).first() if not u: - db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value)) + db.add( + User( + email=admin_email, + password_hash=hash_password(admin_pw), + role=Role.admin.value, + theme_key="default", + ) + ) db.commit() @app.on_event("startup") def on_startup(): + # create new tables Base.metadata.create_all(bind=engine) + db = SessionLocal() try: + _auto_migrate(db) ensure_admin(db) seed_entries(db) finally: diff --git a/backend/app/models.py b/backend/app/models.py index 521b47a..7191f91 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,7 @@ import enum import uuid from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func from .db import Base @@ -21,14 +21,39 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String) role: Mapped[str] = mapped_column(String, default=Role.user.value) disabled: Mapped[bool] = mapped_column(Boolean, default=False) + + # UI preferences (persisted server-side) + theme_key: Mapped[str] = mapped_column(String, default="default") + created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) class Game(Base): __tablename__ = "games" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + + # Creator/owner (for audit), membership controls access owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + name: Mapped[str] = mapped_column(String) seed: Mapped[int] = mapped_column(Integer) + + # "Kahoot"-style join code + join_code: Mapped[str] = mapped_column(String, unique=True, index=True) + + # Winner (shared for the game) + winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) + + created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + +class GameMember(Base): + __tablename__ = "game_members" + __table_args__ = ( + UniqueConstraint("game_id", "user_id", name="uq_game_member"), + ) + + 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) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) class Entry(Base): @@ -46,6 +71,10 @@ class SheetState(Base): game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True) owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True) - status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed + + status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's' + + # Frontend "s.XX" chip selection (persisted) + chip_code: Mapped[str | None] = mapped_column(String, nullable=True) \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 6ece26a..45a2ec7 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,8 +1,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session + from ..db import get_db from ..models import User -from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password +from ..security import ( + verify_password, + make_session_value, + set_session, + clear_session, + get_session_user_id, + hash_password, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -10,11 +18,11 @@ router = APIRouter(prefix="/auth", tags=["auth"]) def login(data: dict, resp: Response, db: Session = Depends(get_db)): email = (data.get("email") or "").lower().strip() password = data.get("password") or "" - user = db.query(User).filter(User.email == email, User.disabled == False).first() + user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712 if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="invalid credentials") set_session(resp, make_session_value(user.id)) - return {"ok": True, "role": user.role, "email": user.email} + return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key} @router.post("/logout") def logout(resp: Response): @@ -29,8 +37,8 @@ def me(req: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.id == uid).first() if not user: raise HTTPException(status_code=401, detail="not logged in") - return {"id": user.id, "email": user.email, "role": user.role} - + return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key} + @router.patch("/password") def set_password(data: dict, req: Request, db: Session = Depends(get_db)): uid = get_session_user_id(req) @@ -41,7 +49,7 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)): if len(password) < 8: raise HTTPException(status_code=400, detail="password too short (min 8)") - user = db.query(User).filter(User.id == uid, User.disabled == False).first() + user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712 if not user: raise HTTPException(status_code=401, detail="not logged in") @@ -49,4 +57,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)): db.add(user) db.commit() - return {"ok": True} \ No newline at end of file + return {"ok": True} + +@router.patch("/theme") +def set_theme(data: dict, req: Request, db: Session = Depends(get_db)): + """Persist user design selection server-side.""" + uid = get_session_user_id(req) + if not uid: + raise HTTPException(status_code=401, detail="not logged in") + + theme_key = (data.get("theme_key") or "").strip() + if not theme_key: + raise HTTPException(status_code=400, detail="theme_key required") + + user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712 + if not user: + raise HTTPException(status_code=401, detail="not logged in") + + user.theme_key = theme_key + db.add(user) + db.commit() + + return {"ok": True, "theme_key": user.theme_key} + \ No newline at end of file diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 2e7fb99..38b772b 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -1,13 +1,16 @@ -import hashlib, random +import hashlib +import random +import string from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session + from ..db import get_db -from ..models import Game, Entry, SheetState, Category +from ..models import Game, GameMember, Entry, SheetState, Category, User, Role from ..security import get_session_user_id router = APIRouter(prefix="/games", tags=["games"]) -def require_user(req: Request, db: Session): +def require_user(req: Request, db: Session) -> str: uid = get_session_user_id(req) if not uid: raise HTTPException(status_code=401, detail="not logged in") @@ -17,30 +20,139 @@ def stable_order(seed: int, user_id: str, entry_id: str) -> str: s = f"{seed}:{user_id}:{entry_id}".encode() return hashlib.sha256(s).hexdigest() +def _rand_join_code(n: int = 6) -> str: + return "".join(random.choice(string.digits) for _ in range(n)) + +def _new_unique_join_code(db: Session) -> str: + for _ in range(50): + code = _rand_join_code() + if not db.query(Game).filter(Game.join_code == code).first(): + return code + raise HTTPException(500, "failed to generate join code") + +def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]: + uid = require_user(req, db) + g = db.query(Game).filter(Game.id == game_id).first() + if not g: + raise HTTPException(404, "game not found") + m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first() + if not m: + raise HTTPException(403, "not a member of this game") + return uid, g + @router.post("") def create_game(req: Request, data: dict, db: Session = Depends(get_db)): uid = require_user(req, db) name = data.get("name") or "Neues Spiel" seed = random.randint(1, 2_000_000_000) - g = Game(owner_user_id=uid, name=name, seed=seed) - db.add(g); db.commit() - return {"id": g.id, "name": g.name} + join_code = _new_unique_join_code(db) + + g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code) + db.add(g) + db.commit() + + # creator becomes member + db.add(GameMember(game_id=g.id, user_id=uid)) + db.commit() + + return {"id": g.id, "name": g.name, "join_code": g.join_code} + +@router.post("/join") +def join_game(req: Request, data: dict, db: Session = Depends(get_db)): + uid = require_user(req, db) + code = (data.get("code") or "").strip() + if not code or len(code) < 4: + raise HTTPException(400, "code required") + + g = db.query(Game).filter(Game.join_code == code).first() + if not g: + raise HTTPException(404, "game not found") + + exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first() + if not exists: + db.add(GameMember(game_id=g.id, user_id=uid)) + db.commit() + + return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}} @router.get("") def list_games(req: Request, db: Session = Depends(get_db)): uid = require_user(req, db) - games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all() - return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games] + + games = ( + db.query(Game) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid) + .order_by(Game.created_at.desc()) + .all() + ) + + out = [] + for g in games: + winner = None + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + if wu: + winner = {"id": wu.id, "email": wu.email} + out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner}) + return out + +@router.get("/{game_id}/meta") +def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)): + uid, g = require_member(req, db, game_id) + + winner = None + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + if wu: + winner = {"id": wu.id, "email": wu.email} + return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner} + +@router.get("/{game_id}/players") +def list_players(req: Request, game_id: str, db: Session = Depends(get_db)): + _uid, g = require_member(req, db, game_id) + + # only non-admin users (admin doesn't play) + players = ( + db.query(User) + .join(GameMember, GameMember.user_id == User.id) + .filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712 + .order_by(User.email.asc()) + .all() + ) + return [{"id": u.id, "email": u.email} for u in players] + +@router.patch("/{game_id}/winner") +def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)): + _uid, g = require_member(req, db, game_id) + + winner_user_id = data.get("winner_user_id") + if winner_user_id is not None: + # must be a member + non-admin + u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712 + if not u or u.role != Role.user.value: + raise HTTPException(400, "invalid winner_user_id") + + member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first() + if not member: + raise HTTPException(400, "winner is not in this game") + + g.winner_user_id = winner_user_id + db.add(g) + db.commit() + + return {"ok": True, "winner_user_id": g.winner_user_id} @router.get("/{game_id}/sheet") def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): - uid = require_user(req, db) - g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() - if not g: - raise HTTPException(404, "game not found") + uid, g = require_member(req, db, game_id) entries = db.query(Entry).all() - states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all() + states = ( + db.query(SheetState) + .filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid) + .all() + ) state_map = {st.entry_id: st for st in states} out = {"suspect": [], "item": [], "location": []} @@ -51,6 +163,7 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): "label": e.label, "status": st.status if st else 0, "note_tag": st.note_tag if st else None, + "chip_code": st.chip_code if st else None, "order": stable_order(g.seed, uid, e.id), } out[e.category].append(item) @@ -65,19 +178,22 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): @router.patch("/{game_id}/sheet/{entry_id}") def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)): - uid = require_user(req, db) - g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() - if not g: - raise HTTPException(404, "game not found") + uid, g = require_member(req, db, game_id) status = data.get("status") note_tag = data.get("note_tag") + chip_code = data.get("chip_code") if note_tag not in (None, "i", "m", "s"): raise HTTPException(400, "invalid note_tag") if status is not None and status not in (0, 1, 2, 3): raise HTTPException(400, "invalid status") + # chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's') + if chip_code is not None: + if not isinstance(chip_code, str) or len(chip_code) > 16: + raise HTTPException(400, "invalid chip_code") + st = db.query(SheetState).filter( SheetState.game_id == g.id, SheetState.owner_user_id == uid, @@ -85,13 +201,24 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi ).first() if not st: - st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None) + st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None) db.add(st) if status is not None: st.status = status + if "note_tag" in data: st.note_tag = note_tag + # if leaving 's', clear chip + if note_tag != "s": + st.chip_code = None + + if "chip_code" in data: + # chip_code is only meaningful when note_tag is 's' + effective_tag = st.note_tag + if effective_tag != "s": + raise HTTPException(400, "chip_code requires note_tag 's'") + st.chip_code = chip_code db.commit() return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e637ae2..0930182 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,13 @@ // src/App.jsx -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; -import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; - -import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { styles } from "./styles/styles"; -import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes"; +import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import AdminPanel from "./components/AdminPanel"; import LoginPage from "./components/LoginPage"; @@ -23,6 +20,7 @@ import SheetSection from "./components/SheetSection"; import DesignModal from "./components/DesignModal"; import WinnerCard from "./components/WinnerCard"; import WinnerBadge from "./components/WinnerBadge"; +import JoinGameModal from "./components/JoinGameModal"; export default function App() { useHpGlobalStyles(); @@ -39,8 +37,10 @@ export default function App() { const [sheet, setSheet] = useState(null); const [pulseId, setPulseId] = useState(null); - // Winner (per game) - const [winnerName, setWinnerName] = useState(""); + // Game meta / players / winner + const [gameMeta, setGameMeta] = useState(null); + const [players, setPlayers] = useState([]); + const [winnerUserId, setWinnerUserId] = useState(null); // Modals const [helpOpen, setHelpOpen] = useState(false); @@ -60,13 +60,20 @@ export default function App() { const [designOpen, setDesignOpen] = useState(false); const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY); + // Join game + const [joinOpen, setJoinOpen] = useState(false); + + const currentGame = useMemo( + () => games.find((g) => String(g.id) === String(gameId)) || null, + [games, gameId] + ); + // ===== Data loaders ===== const load = async () => { const m = await api("/auth/me"); setMe(m); - // Theme pro User laden & anwenden - const tk = loadThemeKey(m?.email); + const tk = m?.theme_key || DEFAULT_THEME_KEY; setThemeKey(tk); applyTheme(tk); @@ -82,6 +89,19 @@ export default function App() { setSheet(sh); }; + const reloadMeta = async () => { + if (!gameId) return; + const meta = await api(`/games/${gameId}/meta`); + setGameMeta(meta); + setWinnerUserId(meta?.winner?.id || null); + }; + + const reloadPlayers = async () => { + if (!gameId) return; + const ps = await api(`/games/${gameId}/players`); + setPlayers(ps); + }; + // ===== Effects ===== // Dropdown outside click @@ -106,19 +126,13 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // load sheet + winner when game changes + // load sheet/meta when game changes useEffect(() => { (async () => { if (!gameId) return; - try { - await reloadSheet(); - } catch { - // ignore - } - - // Sieger pro Game aus localStorage laden - setWinnerName(getWinnerLS(gameId)); + await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]); + } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameId]); @@ -138,7 +152,9 @@ export default function App() { setGames([]); setGameId(null); setSheet(null); - setWinnerName(""); + setGameMeta(null); + setPlayers([]); + setWinnerUserId(null); }; // ===== Password change ===== @@ -184,10 +200,19 @@ export default function App() { setUserMenuOpen(false); }; - const selectTheme = (key) => { + const selectTheme = async (key) => { setThemeKey(key); applyTheme(key); - saveThemeKey(me?.email, key); + + try { + await api("/auth/theme", { + method: "PATCH", + body: JSON.stringify({ theme_key: key }), + }); + setMe((prev) => (prev ? { ...prev, theme_key: key } : prev)); + } catch { + // ignore; UI already switched + } }; // ===== Game actions ===== @@ -196,29 +221,34 @@ export default function App() { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), }); - const gs = await api("/games"); setGames(gs); setGameId(g.id); - - // Neues Spiel -> Sieger leer - clearWinnerLS(g.id); - setWinnerName(""); }; - // ===== Winner actions ===== - const saveWinner = () => { + const openJoinModal = () => { + setJoinOpen(true); + setUserMenuOpen(false); + }; + + const joinGame = async (code) => { + const res = await api("/games/join", { + method: "POST", + body: JSON.stringify({ code }), + }); + const gs = await api("/games"); + setGames(gs); + setGameId(res?.game?.id || null); + }; + + // ===== Winner actions (shared per game) ===== + const saveWinner = async () => { if (!gameId) return; - const v = (winnerName || "").trim(); - - if (!v) { - clearWinnerLS(gameId); - setWinnerName(""); - return; - } - - setWinnerLS(gameId, v); - setWinnerName(v); + await api(`/games/${gameId}/winner`, { + method: "PATCH", + body: JSON.stringify({ winner_user_id: winnerUserId || null }), + }); + await reloadMeta(); }; // ===== Sheet actions ===== @@ -248,8 +278,6 @@ export default function App() { return; } - if (next === null) clearChipLS(gameId, entry.entry_id); - await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", body: JSON.stringify({ note_tag: next }), @@ -265,12 +293,10 @@ export default function App() { setChipOpen(false); setChipEntry(null); - setChipLS(gameId, entry.entry_id, chip); - try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: "s" }), + body: JSON.stringify({ note_tag: "s", chip_code: chip }), }); } finally { await reloadSheet(); @@ -287,8 +313,6 @@ export default function App() { setChipOpen(false); setChipEntry(null); - clearChipLS(gameId, entry.entry_id); - try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", @@ -303,8 +327,7 @@ export default function App() { const t = entry.note_tag; if (!t) return "—"; if (t === "s") { - const chip = getChipLS(gameId, entry.entry_id); - return chip ? `s.${chip}` : "s"; + return entry.chip_code ? `s.${entry.chip_code}` : "s"; } return t; }; @@ -332,6 +355,8 @@ export default function App() { ] : []; + const winnerObj = gameMeta?.winner || null; + return (
); diff --git a/frontend/src/components/JoinGameModal.jsx b/frontend/src/components/JoinGameModal.jsx new file mode 100644 index 0000000..52486b0 --- /dev/null +++ b/frontend/src/components/JoinGameModal.jsx @@ -0,0 +1,74 @@ +// src/components/JoinGameModal.jsx +import React, { useEffect, useState } from "react"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +export default function JoinGameModal({ open, onClose, onJoin }) { + const [code, setCode] = useState(""); + const [msg, setMsg] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!open) return; + setCode(""); + setMsg(""); + setBusy(false); + }, [open]); + + if (!open) return null; + + const doJoin = async () => { + const c = (code || "").trim(); + if (!c) return setMsg("❌ Bitte Code eingeben."); + setBusy(true); + setMsg(""); + try { + await onJoin(c); + } catch (e) { + setMsg("❌ Fehler: " + (e?.message || "unknown")); + setBusy(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+
Spiel beitreten
+ +
+ +
+ setCode(e.target.value)} + placeholder="z.B. 123456" + style={styles.input} + inputMode="numeric" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") doJoin(); + }} + /> + + {msg &&
{msg}
} + +
+ + +
+ +
+ Tipp: Der Spiel-Code steht beim Host unter dem Spiel-Dropdown. +
+
+
+
+ ); +} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 7a74545..d5259eb 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -8,16 +8,19 @@ export default function TopBar({ setUserMenuOpen, openPwModal, openDesignModal, + openJoinModal, doLogout, newGame, }) { return (
+ {/* LINKS */}
Notizbogen
{me.email}
+ {/* RECHTS */}
- + +
diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 64ac143..ea5edec 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -1,43 +1,27 @@ -// src/components/WinnerBadge.jsx import React from "react"; -import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; -export default function WinnerBadge({ winner }) { - const w = (winner || "").trim(); - if (!w) return null; +export default function WinnerBadge({ winnerEmail }) { + if (!winnerEmail) return null; return ( -
-
-
-
🏆 Sieger
-
{w}
-
- -
- Gewonnen -
-
+
+ 🏆 + Sieger: + {winnerEmail}
); } diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index e365d0b..fc1b17b 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -1,31 +1,55 @@ +// src/components/WinnerCard.jsx import React from "react"; import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; -export default function WinnerCard({ value, setValue, onSave }) { +/** + * props: + * - players: [{id,email}] + * - winnerUserId: string|null + * - setWinnerUserId: fn + * - onSave: fn (async ok) + */ +export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) { + const hasPlayers = Array.isArray(players) && players.length > 0; + return (
Sieger
-
- setValue(e.target.value)} - placeholder="Name des Siegers" - style={{ ...styles.input, flex: 1 }} - onKeyDown={(e) => { - if (e.key === "Enter") onSave(); - }} - /> +
+ {!hasPlayers ? ( +
+ Keine Spieler gefunden (Admin wird nicht angezeigt). +
+ ) : ( + + )} - -
+
+ + +
-
- Wird pro Spiel lokal gespeichert. +
+ Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar. +
From 4669d1f8c4f2d03802303e36584465cb16877bab Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 11:21:43 +0100 Subject: [PATCH 02/25] Refactor and enhance game management, user roles, and state handling This commit introduces significant changes across the backend and frontend to improve game creation, joining, and member management. Key updates include adding a host role, structured handling of winners, and a New Game modal in the frontend. The refactor also simplifies join codes, improves persistence for user themes, and enhances overall user interaction with better UI feedback and logic. --- backend/app/models.py | 42 ++-- backend/app/routes/auth.py | 15 +- backend/app/routes/games.py | 226 +++++++++++++-------- frontend/src/App.jsx | 134 ++++++------ frontend/src/components/GamePickerCard.jsx | 27 ++- frontend/src/components/NewGameModal.jsx | 180 ++++++++++++++++ frontend/src/components/TopBar.jsx | 44 ++-- frontend/src/components/WinnerBadge.jsx | 21 +- frontend/src/components/WinnerCard.jsx | 67 +++--- 9 files changed, 488 insertions(+), 268 deletions(-) create mode 100644 frontend/src/components/NewGameModal.jsx diff --git a/backend/app/models.py b/backend/app/models.py index 7191f91..8508cf7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,19 +1,30 @@ import enum import uuid -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint +from sqlalchemy import ( + String, + Boolean, + DateTime, + ForeignKey, + Integer, + SmallInteger, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func from .db import Base + class Role(str, enum.Enum): admin = "admin" user = "user" + class Category(str, enum.Enum): suspect = "suspect" item = "item" location = "location" + class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) @@ -21,40 +32,39 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String) role: Mapped[str] = mapped_column(String, default=Role.user.value) disabled: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) - # UI preferences (persisted server-side) + # NEW: Theme im Userprofil (damit es auf anderen Geräten mitkommt) theme_key: Mapped[str] = mapped_column(String, default="default") - created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) class Game(Base): __tablename__ = "games" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - # Creator/owner (for audit), membership controls access - owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + # NEW: Host (nur Host darf Winner setzen) + host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) name: Mapped[str] = mapped_column(String) seed: Mapped[int] = mapped_column(Integer) + created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) - # "Kahoot"-style join code - join_code: Mapped[str] = mapped_column(String, unique=True, index=True) + # NEW: Join-Code (Kahoot-Style) + code: Mapped[str] = mapped_column(String, unique=True, index=True) - # Winner (shared for the game) + # NEW: Winner (aus Users, nicht Freitext) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) - created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) class GameMember(Base): __tablename__ = "game_members" - __table_args__ = ( - UniqueConstraint("game_id", "user_id", name="uq_game_member"), - ) + __table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_member"),) 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) - created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + class Entry(Base): __tablename__ = "entries" @@ -62,11 +72,13 @@ class Entry(Base): category: Mapped[str] = mapped_column(String, index=True) label: Mapped[str] = mapped_column(String) + class SheetState(Base): __tablename__ = "sheet_state" __table_args__ = ( UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"), ) + 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) owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) @@ -75,6 +87,6 @@ class SheetState(Base): status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's' - # Frontend "s.XX" chip selection (persisted) - chip_code: Mapped[str | None] = mapped_column(String, nullable=True) + # NEW: Chip persistieren (statt LocalStorage) + chip: Mapped[str | None] = mapped_column(String, nullable=True) \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 45a2ec7..a0ff439 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session - from ..db import get_db from ..models import User from ..security import ( @@ -14,21 +13,25 @@ from ..security import ( router = APIRouter(prefix="/auth", tags=["auth"]) + @router.post("/login") def login(data: dict, resp: Response, db: Session = Depends(get_db)): email = (data.get("email") or "").lower().strip() password = data.get("password") or "" - user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712 + user = db.query(User).filter(User.email == email, User.disabled == False).first() if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="invalid credentials") + set_session(resp, make_session_value(user.id)) return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key} + @router.post("/logout") def logout(resp: Response): clear_session(resp) return {"ok": True} + @router.get("/me") def me(req: Request, db: Session = Depends(get_db)): uid = get_session_user_id(req) @@ -39,6 +42,7 @@ def me(req: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=401, detail="not logged in") return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key} + @router.patch("/password") def set_password(data: dict, req: Request, db: Session = Depends(get_db)): uid = get_session_user_id(req) @@ -49,19 +53,18 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)): if len(password) < 8: raise HTTPException(status_code=400, detail="password too short (min 8)") - user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712 + user = db.query(User).filter(User.id == uid, User.disabled == False).first() if not user: raise HTTPException(status_code=401, detail="not logged in") user.password_hash = hash_password(password) db.add(user) db.commit() - return {"ok": True} + @router.patch("/theme") def set_theme(data: dict, req: Request, db: Session = Depends(get_db)): - """Persist user design selection server-side.""" uid = get_session_user_id(req) if not uid: raise HTTPException(status_code=401, detail="not logged in") @@ -70,7 +73,7 @@ def set_theme(data: dict, req: Request, db: Session = Depends(get_db)): if not theme_key: raise HTTPException(status_code=400, detail="theme_key required") - user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712 + user = db.query(User).filter(User.id == uid, User.disabled == False).first() if not user: raise HTTPException(status_code=401, detail="not logged in") diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 38b772b..71c07b7 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -1,151 +1,193 @@ -import hashlib -import random -import string +import hashlib, random from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session - from ..db import get_db -from ..models import Game, GameMember, Entry, SheetState, Category, User, Role +from ..models import Game, Entry, SheetState, Category, GameMember, User, Role from ..security import get_session_user_id router = APIRouter(prefix="/games", tags=["games"]) -def require_user(req: Request, db: Session) -> str: + +def require_user(req: Request, db: Session): uid = get_session_user_id(req) if not uid: raise HTTPException(status_code=401, detail="not logged in") return uid + def stable_order(seed: int, user_id: str, entry_id: str) -> str: s = f"{seed}:{user_id}:{entry_id}".encode() return hashlib.sha256(s).hexdigest() -def _rand_join_code(n: int = 6) -> str: - return "".join(random.choice(string.digits) for _ in range(n)) -def _new_unique_join_code(db: Session) -> str: - for _ in range(50): - code = _rand_join_code() - if not db.query(Game).filter(Game.join_code == code).first(): - return code - raise HTTPException(500, "failed to generate join code") +CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" -def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]: - uid = require_user(req, db) + +def gen_code(n=6) -> str: + return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) + + +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() + if ex: + return + db.add(GameMember(game_id=game_id, user_id=user_id)) + db.commit() + + +def require_game_member(db: Session, game_id: str, user_id: str) -> Game: g = db.query(Game).filter(Game.id == game_id).first() if not g: raise HTTPException(404, "game not found") - m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first() - if not m: + + mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() + if not mem: raise HTTPException(403, "not a member of this game") - return uid, g + return g + @router.post("") def create_game(req: Request, data: dict, db: Session = Depends(get_db)): uid = require_user(req, db) + name = data.get("name") or "Neues Spiel" seed = random.randint(1, 2_000_000_000) - join_code = _new_unique_join_code(db) - g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code) + # unique code + code = gen_code() + while db.query(Game).filter(Game.code == code).first(): + code = gen_code() + + g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None) db.add(g) db.commit() - # creator becomes member - db.add(GameMember(game_id=g.id, user_id=uid)) - db.commit() + # creator joins automatically + ensure_member(db, g.id, uid) + + return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} - return {"id": g.id, "name": g.name, "join_code": g.join_code} @router.post("/join") def join_game(req: Request, data: dict, db: Session = Depends(get_db)): uid = require_user(req, db) - code = (data.get("code") or "").strip() - if not code or len(code) < 4: + code = (data.get("code") or "").strip().upper() + if not code: raise HTTPException(400, "code required") - g = db.query(Game).filter(Game.join_code == code).first() + g = db.query(Game).filter(Game.code == code).first() if not g: raise HTTPException(404, "game not found") - exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first() - if not exists: - db.add(GameMember(game_id=g.id, user_id=uid)) - db.commit() + 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, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}} @router.get("") def list_games(req: Request, db: Session = Depends(get_db)): uid = require_user(req, db) - games = ( + # list games where user is member + q = ( db.query(Game) .join(GameMember, GameMember.game_id == Game.id) .filter(GameMember.user_id == uid) .order_by(Game.created_at.desc()) - .all() ) + games = q.all() + # winner email (optional) out = [] for g in games: - winner = None + winner_email = None if g.winner_user_id: wu = db.query(User).filter(User.id == g.winner_user_id).first() - if wu: - winner = {"id": wu.id, "email": wu.email} - out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner}) + winner_email = wu.email if wu else None + out.append( + { + "id": g.id, + "name": g.name, + "seed": g.seed, + "code": g.code, + "host_user_id": g.host_user_id, + "winner_user_id": g.winner_user_id, + "winner_email": winner_email, + } + ) return out -@router.get("/{game_id}/meta") -def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)): - uid, g = require_member(req, db, game_id) - winner = None +@router.get("/{game_id}") +def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + + winner_email = None if g.winner_user_id: wu = db.query(User).filter(User.id == g.winner_user_id).first() - if wu: - winner = {"id": wu.id, "email": wu.email} - return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner} + winner_email = wu.email if wu else None -@router.get("/{game_id}/players") -def list_players(req: Request, game_id: str, db: Session = Depends(get_db)): - _uid, g = require_member(req, db, game_id) + return { + "id": g.id, + "name": g.name, + "code": g.code, + "host_user_id": g.host_user_id, + "winner_user_id": g.winner_user_id, + "winner_email": winner_email, + } - # only non-admin users (admin doesn't play) - players = ( + +@router.get("/{game_id}/members") +def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + _g = require_game_member(db, game_id, uid) + + # return only "user" role (admin excluded) + members = ( db.query(User) .join(GameMember, GameMember.user_id == User.id) - .filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712 + .filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False) .order_by(User.email.asc()) .all() ) - return [{"id": u.id, "email": u.email} for u in players] + return [{"id": u.id, "email": u.email} for u in members] + @router.patch("/{game_id}/winner") def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)): - _uid, g = require_member(req, db, game_id) + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + + # only host can set winner + if g.host_user_id != uid: + raise HTTPException(403, "only host can set winner") winner_user_id = data.get("winner_user_id") - if winner_user_id is not None: - # must be a member + non-admin - u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712 - if not u or u.role != Role.user.value: - raise HTTPException(400, "invalid winner_user_id") + if winner_user_id is None: + g.winner_user_id = None + db.add(g) + db.commit() + return {"ok": True, "winner_user_id": None} - member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first() - if not member: - raise HTTPException(400, "winner is not in this game") + # must be a member AND role=user + member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first() + if not member: + raise HTTPException(400, "winner must be a member of the game") + + u = db.query(User).filter(User.id == winner_user_id).first() + if not u or u.role != Role.user.value or u.disabled: + raise HTTPException(400, "invalid winner") g.winner_user_id = winner_user_id db.add(g) db.commit() - return {"ok": True, "winner_user_id": g.winner_user_id} + @router.get("/{game_id}/sheet") def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): - uid, g = require_member(req, db, game_id) + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) entries = db.query(Entry).all() states = ( @@ -163,12 +205,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): "label": e.label, "status": st.status if st else 0, "note_tag": st.note_tag if st else None, - "chip_code": st.chip_code if st else None, + "chip": st.chip if st else None, # NEW "order": stable_order(g.seed, uid, e.id), } out[e.category].append(item) - # sort within category for k in out: out[k].sort(key=lambda x: x["order"]) for i in out[k]: @@ -176,49 +217,58 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): return out + @router.patch("/{game_id}/sheet/{entry_id}") def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)): - uid, g = require_member(req, db, game_id) + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) status = data.get("status") note_tag = data.get("note_tag") - chip_code = data.get("chip_code") + chip = data.get("chip") if note_tag not in (None, "i", "m", "s"): raise HTTPException(400, "invalid note_tag") if status is not None and status not in (0, 1, 2, 3): raise HTTPException(400, "invalid status") - # chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's') - if chip_code is not None: - if not isinstance(chip_code, str) or len(chip_code) > 16: - raise HTTPException(400, "invalid chip_code") + if chip is not None: + chip = (chip or "").strip().upper() + if chip == "": + chip = None + if chip is not None: + if len(chip) > 8: + raise HTTPException(400, "invalid chip") - st = db.query(SheetState).filter( - SheetState.game_id == g.id, - SheetState.owner_user_id == uid, - SheetState.entry_id == entry_id - ).first() + st = ( + db.query(SheetState) + .filter( + SheetState.game_id == g.id, + SheetState.owner_user_id == uid, + SheetState.entry_id == entry_id, + ) + .first() + ) if not st: - st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None) + st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None) db.add(st) if status is not None: st.status = status - if "note_tag" in data: st.note_tag = note_tag - # if leaving 's', clear chip - if note_tag != "s": - st.chip_code = None - if "chip_code" in data: - # chip_code is only meaningful when note_tag is 's' - effective_tag = st.note_tag - if effective_tag != "s": - raise HTTPException(400, "chip_code requires note_tag 's'") - st.chip_code = chip_code + # wenn note_tag zurück auf null -> chip auch löschen + if note_tag is None: + st.chip = None + + # chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es) + if "chip" in data: + if st.note_tag == "s": + st.chip = chip + else: + st.chip = None db.commit() return {"ok": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0930182..bb7cc5f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,12 +1,11 @@ -// src/App.jsx -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; +import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { styles } from "./styles/styles"; - import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import AdminPanel from "./components/AdminPanel"; @@ -20,7 +19,7 @@ import SheetSection from "./components/SheetSection"; import DesignModal from "./components/DesignModal"; import WinnerCard from "./components/WinnerCard"; import WinnerBadge from "./components/WinnerBadge"; -import JoinGameModal from "./components/JoinGameModal"; +import NewGameModal from "./components/NewGameModal"; export default function App() { useHpGlobalStyles(); @@ -37,17 +36,17 @@ export default function App() { const [sheet, setSheet] = useState(null); const [pulseId, setPulseId] = useState(null); - // Game meta / players / winner - const [gameMeta, setGameMeta] = useState(null); - const [players, setPlayers] = useState([]); - const [winnerUserId, setWinnerUserId] = useState(null); + // Game meta + const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id} + const [members, setMembers] = useState([]); + + // Winner selection (host only) + const [winnerUserId, setWinnerUserId] = useState(""); // Modals const [helpOpen, setHelpOpen] = useState(false); - const [chipOpen, setChipOpen] = useState(false); const [chipEntry, setChipEntry] = useState(null); - const [userMenuOpen, setUserMenuOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false); @@ -60,15 +59,9 @@ export default function App() { const [designOpen, setDesignOpen] = useState(false); const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY); - // Join game - const [joinOpen, setJoinOpen] = useState(false); + // New Game Modal + const [newGameOpen, setNewGameOpen] = useState(false); - const currentGame = useMemo( - () => games.find((g) => String(g.id) === String(gameId)) || null, - [games, gameId] - ); - - // ===== Data loaders ===== const load = async () => { const m = await api("/auth/me"); setMe(m); @@ -89,21 +82,16 @@ export default function App() { setSheet(sh); }; - const reloadMeta = async () => { + const loadGameMeta = async () => { if (!gameId) return; - const meta = await api(`/games/${gameId}/meta`); + const meta = await api(`/games/${gameId}`); setGameMeta(meta); - setWinnerUserId(meta?.winner?.id || null); - }; + setWinnerUserId(meta?.winner_user_id || ""); - const reloadPlayers = async () => { - if (!gameId) return; - const ps = await api(`/games/${gameId}/players`); - setPlayers(ps); + const mem = await api(`/games/${gameId}/members`); + setMembers(mem); }; - // ===== Effects ===== - // Dropdown outside click useEffect(() => { const onDown = (e) => { @@ -114,24 +102,23 @@ export default function App() { return () => document.removeEventListener("mousedown", onDown); }, [userMenuOpen]); - // initial load (try session) + // initial load useEffect(() => { (async () => { try { await load(); - } catch { - // not logged in - } + } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // load sheet/meta when game changes + // on game change useEffect(() => { (async () => { if (!gameId) return; try { - await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]); + await reloadSheet(); + await loadGameMeta(); } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -153,11 +140,11 @@ export default function App() { setGameId(null); setSheet(null); setGameMeta(null); - setPlayers([]); - setWinnerUserId(null); + setMembers([]); + setWinnerUserId(""); }; - // ===== Password change ===== + // ===== Password ===== const openPwModal = () => { setPwMsg(""); setPw1(""); @@ -194,7 +181,7 @@ export default function App() { } }; - // ===== Theme actions ===== + // ===== Theme ===== const openDesignModal = () => { setDesignOpen(true); setUserMenuOpen(false); @@ -209,26 +196,24 @@ export default function App() { method: "PATCH", body: JSON.stringify({ theme_key: key }), }); - setMe((prev) => (prev ? { ...prev, theme_key: key } : prev)); } catch { - // ignore; UI already switched + // theme locally already applied; ignore backend error } }; - // ===== Game actions ===== - const newGame = async () => { + // ===== New game flow ===== + const createGame = async () => { const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), }); + const gs = await api("/games"); setGames(gs); setGameId(g.id); - }; - const openJoinModal = () => { - setJoinOpen(true); - setUserMenuOpen(false); + // meta/members will load via gameId effect + return g; // includes code }; const joinGame = async (code) => { @@ -236,19 +221,20 @@ export default function App() { method: "POST", body: JSON.stringify({ code }), }); + const gs = await api("/games"); setGames(gs); - setGameId(res?.game?.id || null); + setGameId(res.id); }; - // ===== Winner actions (shared per game) ===== + // ===== Winner ===== const saveWinner = async () => { if (!gameId) return; await api(`/games/${gameId}/winner`, { method: "PATCH", body: JSON.stringify({ winner_user_id: winnerUserId || null }), }); - await reloadMeta(); + await loadGameMeta(); }; // ===== Sheet actions ===== @@ -278,9 +264,11 @@ export default function App() { return; } + if (next === null) clearChipLS(gameId, entry.entry_id); + await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: next }), + body: JSON.stringify({ note_tag: next, chip: null }), }); await reloadSheet(); @@ -293,10 +281,12 @@ export default function App() { setChipOpen(false); setChipEntry(null); + setChipLS(gameId, entry.entry_id, chip); + try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: "s", chip_code: chip }), + body: JSON.stringify({ note_tag: "s", chip }), }); } finally { await reloadSheet(); @@ -313,10 +303,12 @@ export default function App() { setChipOpen(false); setChipEntry(null); + clearChipLS(gameId, entry.entry_id); + try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: null }), + body: JSON.stringify({ note_tag: null, chip: null }), }); } finally { await reloadSheet(); @@ -326,10 +318,14 @@ export default function App() { const displayTag = (entry) => { const t = entry.note_tag; if (!t) return "—"; + if (t === "s") { - return entry.chip_code ? `s.${entry.chip_code}` : "s"; + // Prefer backend chip, fallback localStorage + const chip = entry.chip || getChipLS(gameId, entry.entry_id); + return chip ? `s.${chip}` : "s"; } - return t; + + return t; // i oder m }; // ===== Login page ===== @@ -355,7 +351,7 @@ export default function App() { ] : []; - const winnerObj = gameMeta?.winner || null; + const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id); return (
@@ -370,9 +366,8 @@ export default function App() { setUserMenuOpen={setUserMenuOpen} openPwModal={openPwModal} openDesignModal={openDesignModal} - openJoinModal={openJoinModal} doLogout={doLogout} - newGame={newGame} + onOpenNewGame={() => setNewGameOpen(true)} /> {me.role === "admin" && } @@ -381,11 +376,11 @@ export default function App() { games={games} gameId={gameId} setGameId={setGameId} - joinCode={currentGame?.join_code || ""} onOpenHelp={() => setHelpOpen(true)} /> - {winnerObj && } + {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} + setHelpOpen(false)} /> @@ -403,9 +398,10 @@ export default function App() { ))}
- {/* Sieger (shared per Spiel) */} + {/* Host-only Winner Auswahl */} setDesignOpen(false)} themeKey={themeKey} - onSelect={async (k) => { - await selectTheme(k); + onSelect={(k) => { + selectTheme(k); setDesignOpen(false); }} /> - setJoinOpen(false)} - onJoin={async (code) => { - await joinGame(code); - setJoinOpen(false); - }} + setNewGameOpen(false)} + onCreate={createGame} + onJoin={joinGame} />
@@ -17,7 +16,7 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO > {games.map((g) => ( ))} @@ -27,18 +26,16 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
- {!!joinCode && ( -
- Spiel-Code: {joinCode} -
- )} + {/* kleine Code Zeile unter dem Picker (optional nice) */} + {(() => { + const cur = games.find((x) => x.id === gameId); + if (!cur?.code) return null; + return ( +
+ Code: {cur.code} +
+ ); + })()}
); diff --git a/frontend/src/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx new file mode 100644 index 0000000..4fc036f --- /dev/null +++ b/frontend/src/components/NewGameModal.jsx @@ -0,0 +1,180 @@ +import React, { useMemo, useState } from "react"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +export default function NewGameModal({ + open, + onClose, + onCreate, + onJoin, +}) { + const [mode, setMode] = useState("choice"); // choice | create | join + const [joinCode, setJoinCode] = useState(""); + const [err, setErr] = useState(""); + const [created, setCreated] = useState(null); // { code } + const [toast, setToast] = useState(""); + + const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]); + + if (!open) return null; + + const showToast = (msg) => { + setToast(msg); + setTimeout(() => setToast(""), 1100); + }; + + const doCreate = async () => { + setErr(""); + try { + const res = await onCreate(); + setCreated({ code: res.code }); + setMode("create"); + } catch (e) { + setErr("❌ Fehler: " + (e?.message || "unknown")); + } + }; + + const doJoin = async () => { + setErr(""); + try { + await onJoin(joinCode.trim().toUpperCase()); + onClose(); + } catch (e) { + setErr("❌ Fehler: " + (e?.message || "unknown")); + } + }; + + const copyCode = async () => { + try { + await navigator.clipboard.writeText(created?.code || ""); + showToast("✅ Code kopiert"); + } catch { + showToast("❌ Copy nicht möglich"); + } + }; + + return ( +
+
e.stopPropagation()}> +
+
+ Spiel +
+ +
+ + {/* Toast */} + {toast && ( +
+ {toast} +
+ )} + +
+ {mode === "choice" && ( + <> +
+ Willst du ein Spiel erstellen oder einem Spiel beitreten? +
+ + + + + + )} + + {mode === "join" && ( + <> +
+ Gib den Code ein: +
+ + setJoinCode(e.target.value.toUpperCase())} + placeholder="z.B. 8K3MZQ" + style={styles.input} + autoFocus + /> + +
+ + +
+ + )} + + {mode === "create" && created && ( + <> +
+ Dein Spiel wurde erstellt. Dieser Code bleibt auch bei „Alte Spiele“ sichtbar: +
+ +
+
+ Spiel-Code +
+ +
+ {created.code} +
+ + +
+ +
+ +
+ + )} + + {err &&
{err}
} +
+
+
+ ); +} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index d5259eb..36f5e06 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -8,22 +8,27 @@ export default function TopBar({ setUserMenuOpen, openPwModal, openDesignModal, - openJoinModal, doLogout, - newGame, + onOpenNewGame, // NEW }) { return (
- {/* LINKS */}
-
Notizbogen
-
{me.email}
+
+ Notizbogen +
+
+ {me.email} +
- {/* RECHTS */}
- - - -
diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index ea5edec..8ea36f7 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -8,20 +8,29 @@ export default function WinnerBadge({ winnerEmail }) {
- 🏆 - Sieger: - {winnerEmail} +
+
🏆
+
+ Sieger: + {" "}{winnerEmail} +
+
+ +
+ festgelegt +
); } diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index fc1b17b..c16a12b 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -1,55 +1,42 @@ -// src/components/WinnerCard.jsx import React from "react"; import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; -/** - * props: - * - players: [{id,email}] - * - winnerUserId: string|null - * - setWinnerUserId: fn - * - onSave: fn (async ok) - */ -export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) { - const hasPlayers = Array.isArray(players) && players.length > 0; +export default function WinnerCard({ + isHost, + members, + winnerUserId, + setWinnerUserId, + onSave, +}) { + if (!isHost) return null; return (
Sieger
-
- {!hasPlayers ? ( -
- Keine Spieler gefunden (Admin wird nicht angezeigt). -
- ) : ( - - )} +
+ -
- - -
+ +
-
- Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar. -
+
+ Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
From 8e5a2426e7e783616c6bc3bcce6ab1387ae98522 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 11:37:50 +0100 Subject: [PATCH 03/25] Enhance schema migration with new column checks and backfills This commit updates the schema migration logic to include checks for the existence of columns in a database-agnostic manner, supporting both SQLite and Postgres. It introduces new columns, ensures proper synchronization between old and new column names, adds unique indexes, and backfills missing data. This improves database compatibility and ensures data consistency for evolving schemas. --- backend/app/main.py | 237 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 190 insertions(+), 47 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 1510220..a954e3d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,76 +32,217 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(games_router) + def _rand_join_code(n: int = 6) -> str: # digits only (kahoot style) return "".join(random.choice(string.digits) for _ in range(n)) + +def _has_column(db: Session, table: str, col: str) -> bool: + """ +SQLite + Postgres friendly check. +We use a pragma first (SQLite), fallback to information_schema. + """ + try: + rows = db.execute(text(f"PRAGMA table_info({table})")).all() + return any(r[1] == col for r in rows) # pragma: column name is at index 1 + except Exception: + db.rollback() + + try: + rows = db.execute( + text( + """ +SELECT column_name +FROM information_schema.columns +WHERE table_name = :t AND column_name = :c + """ + ), + {"t": table, "c": col}, + ).all() + return len(rows) > 0 + except Exception: + db.rollback() + return False + + def _auto_migrate(db: Session): """ Very small, pragmatic auto-migration (no alembic). - creates missing tables via create_all - adds missing columns via ALTER TABLE (best effort) - """ - # Users.theme_key +- supports old schema (join_code/chip_code) and new schema (code/chip) +""" + + # --- users.theme_key --- + if not _has_column(db, "users", "theme_key"): + try: + db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'")) + db.commit() + except Exception: + db.rollback() + + # --- games: code / join_code + winner_user_id + host_user_id (optional) --- + # We support both column names: + # old: join_code + # new: code + has_join_code = _has_column(db, "games", "join_code") + has_code = _has_column(db, "games", "code") + + # If neither exists, create "code" (new preferred) + if not has_join_code and not has_code: + try: + db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR")) + db.commit() + has_code = True + except Exception: + db.rollback() + + # If only join_code exists but your code now expects "code", + # add "code" too and later mirror values. + if has_join_code and not has_code: + try: + db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR")) + db.commit() + has_code = True + except Exception: + db.rollback() + + # winner_user_id + if not _has_column(db, "games", "winner_user_id"): + try: + db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # host_user_id (nice to have for "only host can set winner") + if not _has_column(db, "games", "host_user_id"): + try: + db.execute(text("ALTER TABLE games ADD COLUMN host_user_id VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # --- sheet_state chip / chip_code --- + has_chip_code = _has_column(db, "sheet_state", "chip_code") + has_chip = _has_column(db, "sheet_state", "chip") + + if not has_chip_code and not has_chip: + # prefer "chip" + try: + db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR")) + db.commit() + has_chip = True + except Exception: + db.rollback() + + # if old chip_code exists but new expects chip -> add chip and mirror later + if has_chip_code and not has_chip: + try: + db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR")) + db.commit() + has_chip = True + except Exception: + db.rollback() + + # --- indexes for game code --- + # We create unique index for the column(s) that exist. try: - db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'")) + if has_join_code: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)")) + if has_code: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_code ON games (code)")) db.commit() except Exception: db.rollback() - # Games.join_code + winner_user_id - try: - db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR")) - db.commit() - except Exception: - db.rollback() + # --- backfill code values --- + # 1) if join_code exists and code exists, ensure code mirrors join_code where missing + if has_join_code and has_code: + try: + db.execute(text("UPDATE games SET code = join_code WHERE (code IS NULL OR code = '') AND join_code IS NOT NULL AND join_code <> ''")) + db.commit() + except Exception: + db.rollback() - try: - db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR")) - db.commit() - except Exception: - db.rollback() + # 2) generate missing codes in whichever column we have + # Prefer writing into "code" (new), but also keep join_code in sync if present. + code_col = "code" if has_code else "join_code" if has_join_code else None + if code_col: + try: + missing = db.execute( + text(f"SELECT id FROM games WHERE {code_col} IS NULL OR {code_col} = ''") + ).all() + except Exception: + db.rollback() + missing = [] - # SheetState.chip_code - try: - db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR")) - db.commit() - except Exception: - db.rollback() + if missing: + try: + used_rows = db.execute(text(f"SELECT {code_col} FROM games WHERE {code_col} IS NOT NULL")).all() + used = set([r[0] for r in used_rows if r and r[0]]) + except Exception: + db.rollback() + used = set() - # Ensure unique index for join_code (best effort) - try: - db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)")) - db.commit() - except Exception: - db.rollback() - - # Backfill join_code for existing games - games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711 - if games: - used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]]) - for g in games: - code = _rand_join_code() - while code in used: + for (gid,) in missing: code = _rand_join_code() - g.join_code = code - used.add(code) - db.commit() + while code in used: + code = _rand_join_code() + used.add(code) + + try: + # write into main col + db.execute(text(f"UPDATE games SET {code_col} = :c WHERE id = :id"), {"c": code, "id": gid}) + # keep both in sync if both exist + if has_join_code and code_col == "code": + db.execute(text("UPDATE games SET join_code = :c WHERE id = :id AND (join_code IS NULL OR join_code = '')"), {"c": code, "id": gid}) + if has_code and code_col == "join_code": + db.execute(text("UPDATE games SET code = :c WHERE id = :id AND (code IS NULL OR code = '')"), {"c": code, "id": gid}) + db.commit() + except Exception: + db.rollback() + + # --- backfill host_user_id: default to owner_user_id --- + try: + if _has_column(db, "games", "host_user_id"): + db.execute(text("UPDATE games SET host_user_id = owner_user_id WHERE host_user_id IS NULL OR host_user_id = ''")) + db.commit() + except Exception: + db.rollback() + + # --- backfill membership: ensure owner is member --- + # uses ORM; only relies on existing table GameMember (create_all already ran) + try: + all_games = db.query(Game).all() + for g in all_games: + exists = ( + db.query(GameMember) + .filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id) + .first() + ) + if not exists: + db.add(GameMember(game_id=g.id, user_id=g.owner_user_id)) + db.commit() + except Exception: + db.rollback() + + # --- mirror chip_code -> chip if both exist and chip empty --- + if has_chip_code and has_chip: + try: + db.execute(text("UPDATE sheet_state SET chip = chip_code WHERE (chip IS NULL OR chip = '') AND chip_code IS NOT NULL AND chip_code <> ''")) + db.commit() + except Exception: + db.rollback() - # Backfill membership: ensure owner is member - all_games = db.query(Game).all() - for g in all_games: - exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first() - if not exists: - db.add(GameMember(game_id=g.id, user_id=g.owner_user_id)) - db.commit() def seed_entries(db: Session): if db.query(Entry).count() > 0: return - suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"] - items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"] - locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"] + suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"] + items = ["Schlaftrunk", "Verschwindekabinett", "Portschlüssel", "Impedimenta", "Petrificus Totalus", "Alraune"] + locations = ["Große Halle", "Krankenflügel", "Raum der Wünsche", "Klassenzimmer für Zaubertränke", "Pokalszimmer", "Klassenzimmer für Wahrsagen", "Eulerei", "Bibliothek", "Verteidigung gegen die dunklen Künste"] for s in suspects: db.add(Entry(category=Category.suspect.value, label=s)) @@ -111,6 +252,7 @@ def seed_entries(db: Session): db.add(Entry(category=Category.location.value, label=l)) db.commit() + def ensure_admin(db: Session): admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip() admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") @@ -126,6 +268,7 @@ def ensure_admin(db: Session): ) db.commit() + @app.on_event("startup") def on_startup(): # create new tables From 8b10d699eef29b559328cd8617f375e0caabb233 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 11:53:14 +0100 Subject: [PATCH 04/25] Fix incorrect column update logic in games migration Updated the migration logic to ensure `host_user_id` is referenced properly during updates and checks. This resolves potential issues with assigning `host_user_id` and creating corresponding `GameMember` entries correctly. --- backend/app/main.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index a954e3d..deea084 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -207,7 +207,7 @@ Very small, pragmatic auto-migration (no alembic). # --- backfill host_user_id: default to owner_user_id --- try: if _has_column(db, "games", "host_user_id"): - db.execute(text("UPDATE games SET host_user_id = owner_user_id WHERE host_user_id IS NULL OR host_user_id = ''")) + db.execute(text("UPDATE games SET host_user_id = host_user_id WHERE host_user_id IS NULL OR host_user_id = ''")) db.commit() except Exception: db.rollback() @@ -217,13 +217,16 @@ Very small, pragmatic auto-migration (no alembic). try: all_games = db.query(Game).all() for g in all_games: + host_id = getattr(g, "host_user_id", None) + if not host_id: + continue exists = ( db.query(GameMember) - .filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id) + .filter(GameMember.game_id == g.id, GameMember.user_id == host_id) .first() ) if not exists: - db.add(GameMember(game_id=g.id, user_id=g.owner_user_id)) + db.add(GameMember(game_id=g.id, user_id=host_id)) db.commit() except Exception: db.rollback() From 4a012b7345868384c32eb531eb35b536df86cb4d Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:00:14 +0100 Subject: [PATCH 05/25] Improve database column check and update frontend z-index styling Enhanced the `_has_column` function to handle database dialects cleanly, reducing unnecessary PostgreSQL logs. Adjusted frontend z-index values to ensure proper element stacking in the UI. --- backend/app/main.py | 24 ++++++++++++++++++------ frontend/src/styles/styles.js | 6 +++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index deea084..2ebf45e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -40,22 +40,33 @@ def _rand_join_code(n: int = 6) -> str: def _has_column(db: Session, table: str, col: str) -> bool: """ -SQLite + Postgres friendly check. -We use a pragma first (SQLite), fallback to information_schema. +Postgres + SQLite friendly check without spamming Postgres logs. +- SQLite: PRAGMA table_info +- Postgres: information_schema """ + dialect = None try: - rows = db.execute(text(f"PRAGMA table_info({table})")).all() - return any(r[1] == col for r in rows) # pragma: column name is at index 1 + dialect = db.get_bind().dialect.name # "postgresql" | "sqlite" | ... except Exception: - db.rollback() + dialect = None + if dialect == "sqlite": + try: + rows = db.execute(text(f"PRAGMA table_info({table})")).all() + return any(r[1] == col for r in rows) + except Exception: + db.rollback() + return False + + # default: Postgres (or others) via information_schema try: rows = db.execute( text( """ -SELECT column_name +SELECT 1 FROM information_schema.columns WHERE table_name = :t AND column_name = :c +LIMIT 1 """ ), {"t": table, "c": col}, @@ -64,6 +75,7 @@ WHERE table_name = :t AND column_name = :c except Exception: db.rollback() return False + def _auto_migrate(db: Session): diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index a7b4deb..6d88e8f 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -16,6 +16,8 @@ export const styles = { }, topBar: { + position: "relative", + zIndex: 50, display: "flex", justifyContent: "space-between", alignItems: "center", @@ -153,6 +155,8 @@ export const styles = { // Admin adminWrap: { + position: "relative", + zIndex: 1, marginTop: 14, padding: 12, borderRadius: 16, @@ -471,7 +475,7 @@ export const styles = { background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))", boxShadow: "0 18px 55px rgba(0,0,0,0.70)", overflow: "hidden", - zIndex: 10000, + zIndex: 99999, backdropFilter: "blur(8px)", }, From 3a66c0cf74a30288847bcf58adeb255a6d5a8f30 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:09:21 +0100 Subject: [PATCH 06/25] Add `display_name` support for users in backend and frontend This commit introduces the `display_name` field to the user model. It updates database migrations, API endpoints, and the admin panel to handle this field. Additionally, the `display_name` is now shown in the TopBar and WinnerBadge components, improving user experience. --- backend/app/main.py | 9 ++++ backend/app/models.py | 17 +++----- backend/app/routes/admin.py | 38 ++++++++++++++-- backend/app/routes/auth.py | 3 +- frontend/src/components/AdminPanel.jsx | 58 ++++++++++++++++++++++--- frontend/src/components/TopBar.jsx | 4 +- frontend/src/components/WinnerBadge.jsx | 37 +++++++++++++--- 7 files changed, 140 insertions(+), 26 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 2ebf45e..7cf2fb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -86,6 +86,14 @@ Very small, pragmatic auto-migration (no alembic). - supports old schema (join_code/chip_code) and new schema (code/chip) """ + # --- users.display_name --- + if not _has_column(db, "users", "display_name"): + try: + db.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR DEFAULT ''")) + db.commit() + except Exception: + db.rollback() + # --- users.theme_key --- if not _has_column(db, "users", "theme_key"): try: @@ -279,6 +287,7 @@ def ensure_admin(db: Session): password_hash=hash_password(admin_pw), role=Role.admin.value, theme_key="default", + display_name="Admin", ) ) db.commit() diff --git a/backend/app/models.py b/backend/app/models.py index 8508cf7..4c2f286 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,3 +1,4 @@ +# backend/app/models.py import enum import uuid from sqlalchemy import ( @@ -34,25 +35,24 @@ class User(Base): disabled: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) - # NEW: Theme im Userprofil (damit es auf anderen Geräten mitkommt) theme_key: Mapped[str] = mapped_column(String, default="default") + # NEW: schöner Name für UI (TopBar / WinnerBadge) + display_name: Mapped[str] = mapped_column(String, default="") + class Game(Base): __tablename__ = "games" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - # NEW: Host (nur Host darf Winner setzen) host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) name: Mapped[str] = mapped_column(String) seed: Mapped[int] = mapped_column(Integer) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) - # NEW: Join-Code (Kahoot-Style) code: Mapped[str] = mapped_column(String, unique=True, index=True) - # NEW: Winner (aus Users, nicht Freitext) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) @@ -75,18 +75,15 @@ class Entry(Base): class SheetState(Base): __tablename__ = "sheet_state" - __table_args__ = ( - UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"), - ) + __table_args__ = (UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),) 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) owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True) - status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe - note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's' + status: Mapped[int] = mapped_column(SmallInteger, default=0) + note_tag: Mapped[str | None] = mapped_column(String, nullable=True) - # NEW: Chip persistieren (statt LocalStorage) chip: Mapped[str | None] = mapped_column(String, nullable=True) \ No newline at end of file diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index b609c14..fbcb723 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -19,21 +19,53 @@ def require_admin(req: Request, db: Session) -> User: def list_users(req: Request, db: Session = Depends(get_db)): require_admin(req, db) users = db.query(User).order_by(User.created_at.desc()).all() - return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users] + return [ + { + "id": u.id, + "email": u.email, + "display_name": u.display_name, + "role": u.role, + "disabled": u.disabled, + } + for u in users + ] @router.post("/users") def create_user(req: Request, data: dict, db: Session = Depends(get_db)): require_admin(req, db) email = (data.get("email") or "").lower().strip() password = data.get("password") or "" + display_name = (data.get("display_name") or "").strip() + if not email or not password: raise HTTPException(400, "email/password required") if db.query(User).filter(User.email == email).first(): raise HTTPException(409, "email exists") + role = data.get("role") or Role.user.value if role not in (Role.admin.value, Role.user.value): raise HTTPException(400, "invalid role") - u = User(email=email, password_hash=hash_password(password), role=role) + + u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name) db.add(u); db.commit() return {"ok": True, "id": u.id} - \ No newline at end of file + +@router.delete("/users/{user_id}") +def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): + admin = require_admin(req, db) + + if admin.id == user_id: + raise HTTPException(400, "cannot delete yourself") + + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(404, "not found") + + if u.role == Role.admin.value: + raise HTTPException(400, "cannot delete admin user") + + # soft delete + u.disabled = True + db.add(u) + db.commit() + return {"ok": True} \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index a0ff439..fff2f01 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -40,7 +40,8 @@ def me(req: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.id == uid).first() if not user: raise HTTPException(status_code=401, detail="not logged in") - return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key} + return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name} + @router.patch("/password") diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index a49ac46..29fd712 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -7,6 +7,7 @@ export default function AdminPanel() { const [users, setUsers] = useState([]); const [open, setOpen] = useState(false); + const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [role, setRole] = useState("user"); @@ -22,6 +23,7 @@ export default function AdminPanel() { }, []); const resetForm = () => { + setDisplayName(""); setEmail(""); setPassword(""); setRole("user"); @@ -32,7 +34,7 @@ export default function AdminPanel() { try { await api("/admin/users", { method: "POST", - body: JSON.stringify({ email, password, role }), + body: JSON.stringify({ display_name: displayName, email, password, role }), }); setMsg("✅ User erstellt."); await loadUsers(); @@ -43,6 +45,16 @@ export default function AdminPanel() { } }; + const deleteUser = async (u) => { + if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return; + try { + await api(`/admin/users/${u.id}`, { method: "DELETE" }); + await loadUsers(); + } catch (e) { + alert("Fehler: " + (e?.message || "unknown")); + } + }; + const closeModal = () => { setOpen(false); setMsg(""); @@ -63,14 +75,39 @@ export default function AdminPanel() {
{users.map((u) => ( -
-
{u.email}
+
+
+ {u.display_name || "—"} +
+
{u.email}
{u.role}
{u.disabled ? "disabled" : "active"}
+ +
))}
@@ -88,13 +125,21 @@ export default function AdminPanel() {
+ setDisplayName(e.target.value)} + placeholder="Name (z.B. Sascha)" + style={styles.input} + autoFocus + /> + setEmail(e.target.value)} placeholder="Email" style={styles.input} - autoFocus /> + setPassword(e.target.value)} @@ -102,6 +147,7 @@ export default function AdminPanel() { type="password" style={styles.input} /> + setDisplayName(e.target.value)} diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index 6d88e8f..d6dff21 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -189,25 +189,32 @@ export const styles = { modalOverlay: { position: "fixed", inset: 0, - background: "rgba(0,0,0,0.65)", + background: "rgba(0,0,0,0.78)", // stärker abdunkeln + backdropFilter: "blur(6px)", // Hintergrund weich (macht viel aus) display: "flex", alignItems: "center", justifyContent: "center", padding: 16, zIndex: 9999, animation: "fadeIn 160ms ease-out", + overflowY: "auto", // falls Viewport zu klein }, + modalCard: { width: "100%", maxWidth: 560, borderRadius: 18, border: `1px solid rgba(233,216,166,0.18)`, - background: "linear-gradient(180deg, rgba(20,20,24,0.92), rgba(12,12,14,0.86))", + background: "linear-gradient(180deg, rgba(20,20,24,0.95), rgba(12,12,14,0.92))", boxShadow: "0 18px 55px rgba(0,0,0,0.70)", padding: 14, - backdropFilter: "blur(6px)", + backdropFilter: "blur(8px)", animation: "popIn 160ms ease-out", color: stylesTokens.textMain, + + // neu: damit es nie “kaputt” aussieht + maxHeight: "calc(100dvh - 32px)", + overflow: "auto", }, modalHeader: { display: "flex", From 556a7a5d813890d7421da1800c58adf3849cdc6f Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:29:07 +0100 Subject: [PATCH 10/25] Update WinnerBadge to display displayName instead of email Replaced `winner.email` with `winner.displayName` in the WinnerBadge component. This ensures a more user-friendly representation by showing the display name instead of the email. --- frontend/src/components/WinnerBadge.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 4bc3e91..0ed5fe9 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -49,7 +49,7 @@ export default function WinnerBadge({ winner, winnerEmail }) { {showEmail && (
- {winner.email} + {winner.displayName}
)}
From 52ace41ac4bc86be02a88dd6bae2ba3ae835a67f Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:43:24 +0100 Subject: [PATCH 11/25] Refactor modal logic and implement `ModalPortal` component Moved modal rendering logic to a new `ModalPortal` component to improve reusability and separation of concerns. Adjusted styles for better UI consistency, including improved backdrop and modal behavior. Enhanced accessibility by handling escape key events and blocking background scrolling when the modal is open. --- frontend/src/components/AdminPanel.jsx | 135 +++++++++++++----------- frontend/src/components/ModalPortal.jsx | 40 +++++++ frontend/src/styles/styles.js | 27 +++-- 3 files changed, 127 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/ModalPortal.jsx diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 7b53847..12ef778 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -2,7 +2,19 @@ import React, { useEffect, useState } from "react"; import { api } from "../api/client"; import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; +import { createPortal } from "react-dom"; + +useEffect(() => { + if (!open) return; + + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = prev; + }; + }, [open]); export default function AdminPanel() { const [users, setUsers] = useState([]); @@ -112,71 +124,74 @@ export default function AdminPanel() { ))}
- {open && ( -
-
e.stopPropagation()}> -
-
- Neuen User anlegen -
- -
- -
- setDisplayName(e.target.value)} - placeholder="Name (z.B. Sascha)" - style={styles.input} - autoFocus - /> - - setEmail(e.target.value)} - placeholder="Email" - style={styles.input} - /> - - setPassword(e.target.value)} - placeholder="Initial Passwort" - type="password" - style={styles.input} - /> - - - - {msg &&
{msg}
} - -
- -
-
- Tipp: Name wird in TopBar & Siegeranzeige genutzt. +
+ setDisplayName(e.target.value)} + placeholder="Name (z.B. Sascha)" + style={styles.input} + autoFocus + /> + + setEmail(e.target.value)} + placeholder="Email" + style={styles.input} + /> + + setPassword(e.target.value)} + placeholder="Initial Passwort" + type="password" + style={styles.input} + /> + + + + {msg &&
{msg}
} + +
+ + +
+ +
+ Tipp: Name wird in TopBar & Siegeranzeige genutzt. +
-
-
- )} +
, + document.body + ) + }
); } diff --git a/frontend/src/components/ModalPortal.jsx b/frontend/src/components/ModalPortal.jsx new file mode 100644 index 0000000..8c02f08 --- /dev/null +++ b/frontend/src/components/ModalPortal.jsx @@ -0,0 +1,40 @@ +import React, { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { styles } from "../styles/styles"; + +export default function ModalPortal({ open, onClose, children }) { + useEffect(() => { + if (!open) return; + + const onKeyDown = (e) => { + if (e.key === "Escape") onClose?.(); + }; + + // Scroll der Seite sperren + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
{ + // Klick außerhalb schließt + if (e.target === e.currentTarget) onClose?.(); + }} + > +
e.stopPropagation()}> + {children} +
+
, + document.body + ); +} diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index d6dff21..3573531 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -188,32 +188,29 @@ export const styles = { // Modal modalOverlay: { position: "fixed", - inset: 0, - background: "rgba(0,0,0,0.78)", // stärker abdunkeln - backdropFilter: "blur(6px)", // Hintergrund weich (macht viel aus) + top: 0, + left: 0, + right: 0, + bottom: 0, + width: "100vw", + height: "100vh", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, - zIndex: 9999, - animation: "fadeIn 160ms ease-out", - overflowY: "auto", // falls Viewport zu klein + zIndex: 2147483647, // wirklich ganz oben + background: "rgba(0,0,0,0.72)", + overflowY: "auto", }, modalCard: { - width: "100%", - maxWidth: 560, + width: "min(560px, 100%)", borderRadius: 18, border: `1px solid rgba(233,216,166,0.18)`, - background: "linear-gradient(180deg, rgba(20,20,24,0.95), rgba(12,12,14,0.92))", + background: "rgba(12,12,14,0.96)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)", padding: 14, - backdropFilter: "blur(8px)", - animation: "popIn 160ms ease-out", - color: stylesTokens.textMain, - - // neu: damit es nie “kaputt” aussieht - maxHeight: "calc(100dvh - 32px)", + maxHeight: "calc(100vh - 32px)", overflow: "auto", }, modalHeader: { From b8304282510358276828f2682becf1aaf2d5ec27 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:51:20 +0100 Subject: [PATCH 12/25] Refactor AdminPanel: Move function definition above useEffect. This change reorders the `AdminPanel` function definition to appear before the `useEffect` hook, enhancing readability and maintaining consistent organization. No functionality is altered. --- frontend/src/components/AdminPanel.jsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 12ef778..f0762a4 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -4,17 +4,6 @@ import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; import { createPortal } from "react-dom"; - -useEffect(() => { - if (!open) return; - - const prev = document.body.style.overflow; - document.body.style.overflow = "hidden"; - - return () => { - document.body.style.overflow = prev; - }; - }, [open]); export default function AdminPanel() { const [users, setUsers] = useState([]); @@ -25,6 +14,17 @@ export default function AdminPanel() { const [role, setRole] = useState("user"); const [msg, setMsg] = useState(""); + useEffect(() => { + if (!open) return; + + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + const loadUsers = async () => { const u = await api("/admin/users"); setUsers(u); From bfb1df8e59cd995a23a2c33bad1ecbd1c9fc3092 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 12:56:59 +0100 Subject: [PATCH 13/25] Enhance styling consistency and alignment in Admin Panel Centered elements in the Admin Panel using `justifyItems: "center"`. Adjusted input padding, font size, and primary button styling for improved layout and usability. --- frontend/src/components/AdminPanel.jsx | 9 ++++++++- frontend/src/styles/styles.js | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index f0762a4..b391e81 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -137,7 +137,14 @@ export default function AdminPanel() {
-
+
setDisplayName(e.target.value)} diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index 3573531..8770e5f 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -122,13 +122,13 @@ export const styles = { input: { width: "100%", - padding: 10, - borderRadius: 12, + padding: "10px 12px", + borderRadius: 14, border: `1px solid rgba(233,216,166,0.18)`, background: "rgba(10,10,12,0.55)", color: stylesTokens.textMain, outline: "none", - fontSize: 16, + fontSize: 15, }, primaryBtn: { @@ -197,7 +197,7 @@ export const styles = { display: "flex", alignItems: "center", justifyContent: "center", - padding: 16, + padding: 12, zIndex: 2147483647, // wirklich ganz oben background: "rgba(0,0,0,0.72)", overflowY: "auto", From 59e224b4cad5aa19ba8d2240442d3b1025d4bcdf Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:14:27 +0100 Subject: [PATCH 14/25] Add user stats feature with API and modal integration Introduced an endpoint to fetch user stats and integrated it with a new StatsModal component in the frontend. Users can now view game statistics, including played games, wins, losses, and win rates, accessible from the user menu. --- backend/app/routes/auth.py | 35 ++++++++ frontend/src/App.jsx | 40 ++++++++++ frontend/src/components/StatsModal.jsx | 101 ++++++++++++++++++++++++ frontend/src/components/TopBar.jsx | 21 +++-- frontend/src/components/WinnerBadge.jsx | 2 +- frontend/src/components/WinnerCard.jsx | 2 +- 6 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/StatsModal.jsx diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index fff2f01..d6bb7b0 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -43,6 +43,41 @@ def me(req: Request, db: Session = Depends(get_db)): return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name} +@router.get("/me/stats") +def my_stats(req: Request, db: Session = Depends(get_db)): + uid = get_session_user_id(req) + if not uid: + raise HTTPException(status_code=401, detail="not logged in") + + # "played" = games where user is member AND winner is set (finished games) + from sqlalchemy import func + from ..models import Game, GameMember + + played = ( + db.query(func.count(Game.id)) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid, Game.winner_user_id != None) + .scalar() + or 0 + ) + + wins = ( + db.query(func.count(Game.id)) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid, Game.winner_user_id == uid) + .scalar() + or 0 + ) + + losses = max(int(played) - int(wins), 0) + winrate = (float(wins) / float(played) * 100.0) if played else 0.0 + + return { + "played": int(played), + "wins": int(wins), + "losses": int(losses), + "winrate": round(winrate, 1), + } @router.patch("/password") def set_password(data: dict, req: Request, db: Session = Depends(get_db)): diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bb7cc5f..d74593c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import DesignModal from "./components/DesignModal"; import WinnerCard from "./components/WinnerCard"; import WinnerBadge from "./components/WinnerBadge"; import NewGameModal from "./components/NewGameModal"; +import StatsModal from "./components/StatsModal"; export default function App() { useHpGlobalStyles(); @@ -62,6 +63,12 @@ export default function App() { // New Game Modal const [newGameOpen, setNewGameOpen] = useState(false); + // ===== Stats Modal ===== + const [statsOpen, setStatsOpen] = useState(false); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [statsError, setStatsError] = useState(""); + const load = async () => { const m = await api("/auth/me"); setMe(m); @@ -201,6 +208,29 @@ export default function App() { } }; + // ===== Stats (always fresh on open) ===== + const openStatsModal = async () => { + setUserMenuOpen(false); + setStatsOpen(true); + setStatsError(""); + setStatsLoading(true); + + try { + const s = await api("/auth/me/stats"); + setStats(s); + } catch (e) { + setStats(null); + setStatsError("❌ Fehler: " + (e?.message || "unknown")); + } finally { + setStatsLoading(false); + } + }; + + const closeStatsModal = () => { + setStatsOpen(false); + setStatsError(""); + }; + // ===== New game flow ===== const createGame = async () => { const g = await api("/games", { @@ -366,6 +396,7 @@ export default function App() { setUserMenuOpen={setUserMenuOpen} openPwModal={openPwModal} openDesignModal={openDesignModal} + openStatsModal={openStatsModal} doLogout={doLogout} onOpenNewGame={() => setNewGameOpen(true)} /> @@ -444,6 +475,15 @@ export default function App() { closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} /> + +
); } diff --git a/frontend/src/components/StatsModal.jsx b/frontend/src/components/StatsModal.jsx new file mode 100644 index 0000000..db6b91d --- /dev/null +++ b/frontend/src/components/StatsModal.jsx @@ -0,0 +1,101 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +function Tile({ label, value, sub }) { + return ( +
+
+ {label} +
+ +
+ {value} +
+ + {sub ? ( +
+ {sub} +
+ ) : null} +
+ ); +} + +export default function StatsModal({ open, onClose, me, stats, loading, error }) { + if (!open) return null; + + const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; + + return createPortal( +
+
e.stopPropagation()}> +
+
+
Statistik
+
+ {displayName} +
+
+ + +
+ +
+ {loading ? ( +
+ Lade Statistik… +
+ ) : error ? ( +
{error}
+ ) : ( +
+ + + + +
+ )} +
+ +
+ Hinweis: „Gespielt“ zählt nur Spiele mit gesetztem Sieger. +
+
+
, + document.body + ); +} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 5cb2527..ab4b407 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -8,19 +8,16 @@ export default function TopBar({ setUserMenuOpen, openPwModal, openDesignModal, + openStatsModal, doLogout, onOpenNewGame, }) { - const displayName = me - ? ((me.display_name || "").trim() || me.email) - : ""; + const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; return (
-
- Notizbogen -
+
Notizbogen
{displayName}
@@ -52,6 +49,18 @@ export default function TopBar({ {me?.email || ""}
+ + +
+ diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 0ed5fe9..ae66f41 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -49,7 +49,7 @@ export default function WinnerBadge({ winner, winnerEmail }) { {showEmail && (
- {winner.displayName} + {winner.display_name}
)}
diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index c16a12b..ad7d625 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -25,7 +25,7 @@ export default function WinnerCard({ {members.map((m) => ( ))} From fa89987f394ed3ffe94a074522a9dfa1d3101374 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:22:23 +0100 Subject: [PATCH 15/25] Update components to display email instead of display_name Replaced `display_name` with `email` in `WinnerBadge` and `WinnerCard` components. This ensures email addresses are shown consistently when rendering winner-related information. --- frontend/src/components/WinnerBadge.jsx | 2 +- frontend/src/components/WinnerCard.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index ae66f41..4bc3e91 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -49,7 +49,7 @@ export default function WinnerBadge({ winner, winnerEmail }) { {showEmail && (
- {winner.display_name} + {winner.email}
)}
diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index ad7d625..c16a12b 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -25,7 +25,7 @@ export default function WinnerCard({ {members.map((m) => ( ))} From 745b66170912f32768239b5d3895453a78de4b64 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:24:38 +0100 Subject: [PATCH 16/25] Fix email display logic in WinnerBadge component Replaced the conditional display of the winner's email with a more robust logic to show either the trimmed display name or the email. This ensures better handling of cases where the display name is unavailable or matches the email. --- frontend/src/components/WinnerBadge.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 4bc3e91..8aa4324 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -22,6 +22,8 @@ export default function WinnerBadge({ winner, winnerEmail }) { (winner?.display_name || "").trim() && winner.email.trim().toLowerCase() !== winner.display_name.trim().toLowerCase(); + const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; + return (
- {winner.email} + {displayName}
)}
From 1473100498f67471a9f0c3ad11d7bd94a994b855 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:31:18 +0100 Subject: [PATCH 17/25] Add theme_key to user payload in /me endpoint The /me endpoint now includes the theme_key in the response payload. This ensures the frontend can access the user's theme preference directly. --- backend/app/routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index d6bb7b0..263db56 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -40,7 +40,7 @@ def me(req: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.id == uid).first() if not user: raise HTTPException(status_code=401, detail="not logged in") - return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name} + return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name, "theme_key": user.theme_key} @router.get("/me/stats") From bdc6824e184677a77e4189652e5ca06a85f86af8 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:35:31 +0100 Subject: [PATCH 18/25] Remove unused `displayName` variable from WinnerBadge.jsx The `displayName` variable was declared but not used effectively in the component. This cleanup improves code readability and eliminates unnecessary declarations. Updated the code to directly use `me.display_name`. --- frontend/src/components/WinnerBadge.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 8aa4324..45adbcb 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -22,7 +22,6 @@ export default function WinnerBadge({ winner, winnerEmail }) { (winner?.display_name || "").trim() && winner.email.trim().toLowerCase() !== winner.display_name.trim().toLowerCase(); - const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; return (
- {displayName} + {me.display_name}
)}
From dc98eeb41c92dccf0edfe19a08b2cd258092c3da Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:36:51 +0100 Subject: [PATCH 19/25] Fix email display in WinnerBadge component Replaced the incorrect `me.display_name` with `winner.email` to ensure the correct email is shown when `showEmail` is true. This resolves the display issue for winner information. --- frontend/src/components/WinnerBadge.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 45adbcb..cd4a8b6 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -50,7 +50,7 @@ export default function WinnerBadge({ winner, winnerEmail }) { {showEmail && (
- {me.display_name} + {winner.email}
)}
From 45722e057f8881678a8c1f38a35d62511cbce36b Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:40:10 +0100 Subject: [PATCH 20/25] Refactor modal overlay styles for consistency and responsiveness Replaced top/left/right/bottom with a single "inset" property for cleaner and more concise code. Updated padding to account for safe areas dynamically and ensured box-sizing includes padding. Improved responsiveness by using percentages instead of viewport units for width and height. --- frontend/src/styles/styles.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index 8770e5f..c8c7212 100644 --- a/frontend/src/styles/styles.js +++ b/frontend/src/styles/styles.js @@ -188,17 +188,15 @@ export const styles = { // Modal modalOverlay: { position: "fixed", - top: 0, - left: 0, - right: 0, - bottom: 0, - width: "100vw", - height: "100vh", + inset: 0, // statt top/left/right/bottom + width: "100%", // ✅ NICHT 100vw + height: "100%", // ✅ NICHT 100vh display: "flex", alignItems: "center", justifyContent: "center", - padding: 12, - zIndex: 2147483647, // wirklich ganz oben + padding: "calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))", + boxSizing: "border-box", // wichtig bei padding + zIndex: 2147483647, background: "rgba(0,0,0,0.72)", overflowY: "auto", }, From b4b5c7903a07a2f1d9548e34d656831d06e00151 Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 13:46:02 +0100 Subject: [PATCH 21/25] Remove redundant email display logic in WinnerBadge component. Simplified the WinnerBadge component by removing the conditional logic for optionally displaying the email when the display name is available. Updated WinnerCard to use display name as the primary label fallback for members, ensuring cleaner and consistent rendering. --- frontend/src/components/WinnerBadge.jsx | 25 ++++--------------------- frontend/src/components/WinnerCard.jsx | 13 ++++++++----- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index cd4a8b6..c4fb1c0 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -4,8 +4,7 @@ import { stylesTokens } from "../styles/theme"; /** * Props: * - winner: { display_name?: string, email?: string } | null - * (oder als Fallback:) - * - winnerEmail: string | null + * - winnerEmail: string | null (legacy fallback) */ export default function WinnerBadge({ winner, winnerEmail }) { const name = @@ -15,14 +14,6 @@ export default function WinnerBadge({ winner, winnerEmail }) { if (!name) return null; - // Optional: wenn display_name vorhanden ist, Email klein anzeigen - const showEmail = - winner && - (winner?.email || "").trim() && - (winner?.display_name || "").trim() && - winner.email.trim().toLowerCase() !== winner.display_name.trim().toLowerCase(); - - return (
🏆
-
-
- Sieger: - {" "}{name} -
- - {showEmail && ( -
- {winner.email} -
- )} +
+ Sieger: + {" "}{name}
diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index c16a12b..b76f588 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -23,11 +23,14 @@ export default function WinnerCard({ style={{ ...styles.input, flex: 1 }} > - {members.map((m) => ( - - ))} + {members.map((m) => { + const dn = ((m.display_name || "").trim() || (m.email || "").trim()); + return ( + + ); + })} +
+ +
+ Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten. +
+ +
+ +
+ + )} + + {/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */} {mode === "choice" && ( <>
@@ -159,7 +243,7 @@ export default function NewGameModal({ {created.code}
-