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. +