From 4669d1f8c4f2d03802303e36584465cb16877bab Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 11:21:43 +0100 Subject: [PATCH] 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.