diff --git a/backend/app/main.py b/backend/app/main.py index fc093c2..7cf2fb0 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,14 +32,240 @@ 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: + """ +Postgres + SQLite friendly check without spamming Postgres logs. +- SQLite: PRAGMA table_info +- Postgres: information_schema + """ + dialect = None + try: + dialect = db.get_bind().dialect.name # "postgresql" | "sqlite" | ... + except Exception: + 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 1 +FROM information_schema.columns +WHERE table_name = :t AND column_name = :c +LIMIT 1 + """ + ), + {"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) +- 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: + 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: + 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() + + # --- 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() + + # 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 = [] + + 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() + + for (gid,) in missing: + code = _rand_join_code() + 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 = host_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: + 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 == host_id) + .first() + ) + if not exists: + db.add(GameMember(game_id=g.id, user_id=host_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() + + 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"] - 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)) @@ -41,19 +275,32 @@ 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!") 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", + display_name="Admin", + ) + ) 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..4c2f286 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,19 +1,31 @@ +# backend/app/models.py import enum import uuid -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship +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())) @@ -23,29 +35,55 @@ class User(Base): disabled: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + 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())) - owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + + 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()) + code: Mapped[str] = mapped_column(String, unique=True, index=True) + + winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) + + +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) + joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class Entry(Base): __tablename__ = "entries" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 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"), - ) + __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 - 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) + + 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 6ece26a..263db56 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -2,10 +2,18 @@ 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"]) + @router.post("/login") def login(data: dict, resp: Response, db: Session = Depends(get_db)): email = (data.get("email") or "").lower().strip() @@ -13,14 +21,17 @@ def login(data: dict, resp: Response, db: Session = Depends(get_db)): 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} + 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) @@ -29,8 +40,45 @@ 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, "display_name": user.display_name, "theme_key": user.theme_key} + +@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)): uid = get_session_user_id(req) @@ -48,5 +96,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)): user.password_hash = hash_password(password) db.add(user) db.commit() + return {"ok": True} - return {"ok": True} \ No newline at end of file + +@router.patch("/theme") +def set_theme(data: dict, 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") + + 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() + 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..0cc7e7c 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -2,45 +2,204 @@ import hashlib, random 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, 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): 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() + +CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + + +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") + + 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 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} + + # 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 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} + + +@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().upper() + if not code: + raise HTTPException(400, "code required") + + g = db.query(Game).filter(Game.code == code).first() + if not g: + raise HTTPException(404, "game not found") + + 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} + @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] + + # 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()) + ) + games = q.all() + + # winner email (optional) + out = [] + for g in games: + winner_email = None + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + 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}") +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 + winner_display_name = None + + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + if wu: + winner_email = wu.email + winner_display_name = wu.display_name + + 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, + "winner_display_name": winner_display_name, + } + + +@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 == game_id, User.role == Role.user.value, User.disabled == False) + .order_by(User.email.asc()) + .all() + ) + return [{"id": u.id, "email": u.email, "display_name": u.display_name} 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 = 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 None: + g.winner_user_id = None + db.add(g) + db.commit() + return {"ok": True, "winner_user_id": None} + + # 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 = 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") + g = require_game_member(db, game_id, uid) 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,11 +210,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": 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]: @@ -63,29 +222,41 @@ 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 = 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") + g = require_game_member(db, game_id, uid) status = data.get("status") note_tag = data.get("note_tag") + 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") - st = db.query(SheetState).filter( - SheetState.game_id == g.id, - SheetState.owner_user_id == uid, - SheetState.entry_id == entry_id - ).first() + 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() + ) 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=None) db.add(st) if status is not None: @@ -93,6 +264,17 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi if "note_tag" in data: st.note_tag = note_tag + # 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} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e637ae2..fd762f8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,12 @@ -// src/App.jsx import React, { useEffect, 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 +19,8 @@ import SheetSection from "./components/SheetSection"; 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(); @@ -39,15 +37,17 @@ export default function App() { const [sheet, setSheet] = useState(null); const [pulseId, setPulseId] = useState(null); - // Winner (per game) - const [winnerName, setWinnerName] = useState(""); + // 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,13 +60,20 @@ export default function App() { const [designOpen, setDesignOpen] = useState(false); const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY); - // ===== Data loaders ===== + // 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); - // Theme pro User laden & anwenden - const tk = loadThemeKey(m?.email); + const tk = m?.theme_key || DEFAULT_THEME_KEY; setThemeKey(tk); applyTheme(tk); @@ -82,7 +89,15 @@ export default function App() { setSheet(sh); }; - // ===== Effects ===== + const loadGameMeta = async () => { + if (!gameId) return; + const meta = await api(`/games/${gameId}`); + setGameMeta(meta); + setWinnerUserId(meta?.winner_user_id || ""); + + const mem = await api(`/games/${gameId}/members`); + setMembers(mem); + }; // Dropdown outside click useEffect(() => { @@ -94,35 +109,58 @@ 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 + winner when game changes + // on game change useEffect(() => { (async () => { if (!gameId) return; - try { await reloadSheet(); - } catch { - // ignore - } - - // Sieger pro Game aus localStorage laden - setWinnerName(getWinnerLS(gameId)); + await loadGameMeta(); + } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameId]); + // ✅ Live refresh (Members/Meta) – damit neue Joiner ohne Reload sichtbar sind + // Für 5–6 Spieler reicht 2.5s völlig, ist "live genug" und schont Backend. + useEffect(() => { + if (!me || !gameId) return; + + let alive = true; + + const tick = async () => { + try { + await loadGameMeta(); // refresh members + winner meta + } catch { + // ignore + } + }; + + // sofort einmal ziehen + tick(); + + const id = setInterval(() => { + if (!alive) return; + tick(); + }, 2500); + + return () => { + alive = false; + clearInterval(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id, gameId]); + // ===== Auth actions ===== const doLogin = async () => { await api("/auth/login", { @@ -138,10 +176,12 @@ export default function App() { setGames([]); setGameId(null); setSheet(null); - setWinnerName(""); + setGameMeta(null); + setMembers([]); + setWinnerUserId(""); }; - // ===== Password change ===== + // ===== Password ===== const openPwModal = () => { setPwMsg(""); setPw1(""); @@ -178,20 +218,60 @@ export default function App() { } }; - // ===== Theme actions ===== + // ===== Theme ===== const openDesignModal = () => { setDesignOpen(true); 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 }), + }); + } catch { + // theme locally already applied; ignore backend error + } }; - // ===== Game actions ===== - const newGame = async () => { + // ===== 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 () => { + // ✅ wichtig: alten Game-State weg, damit nix "hängen" bleibt + setSheet(null); + setGameMeta(null); + setMembers([]); + setWinnerUserId(""); + setPulseId(null); + setChipOpen(false); + setChipEntry(null); + const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), @@ -199,26 +279,32 @@ export default function App() { const gs = await api("/games"); setGames(gs); + + // ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect) setGameId(g.id); - // Neues Spiel -> Sieger leer - clearWinnerLS(g.id); - setWinnerName(""); + return g; // includes code }; - // ===== Winner actions ===== - const saveWinner = () => { + 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.id); + }; + + // ===== Winner ===== + 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 loadGameMeta(); }; // ===== Sheet actions ===== @@ -252,7 +338,7 @@ export default function App() { 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(); @@ -270,7 +356,7 @@ export default function App() { try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: "s" }), + body: JSON.stringify({ note_tag: "s", chip }), }); } finally { await reloadSheet(); @@ -292,7 +378,7 @@ export default function App() { 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(); @@ -302,11 +388,14 @@ export default function App() { const displayTag = (entry) => { const t = entry.note_tag; if (!t) return "—"; + if (t === "s") { - const chip = getChipLS(gameId, entry.entry_id); + // 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 ===== @@ -332,6 +421,8 @@ export default function App() { ] : []; + const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id); + return (
); } diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index a49ac46..b391e81 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -2,16 +2,29 @@ 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"; 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"); 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); @@ -22,6 +35,7 @@ export default function AdminPanel() { }, []); const resetForm = () => { + setDisplayName(""); setEmail(""); setPassword(""); setRole("user"); @@ -32,7 +46,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 +57,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,74 +87,118 @@ export default function AdminPanel() {
{users.map((u) => ( -
-
{u.email}
+
+
+ {u.display_name || "—"} +
+
{u.email}
{u.role}
{u.disabled ? "disabled" : "active"}
+ +
))}
- {open && ( -
-
e.stopPropagation()}> -
-
- Neuen User anlegen -
- -
- -
- setEmail(e.target.value)} - placeholder="Email" - style={styles.input} - autoFocus - /> - setPassword(e.target.value)} - placeholder="Initial Passwort" - type="password" - style={styles.input} - /> - - - {msg &&
{msg}
} - -
- -
-
- Tipp: Klick auf Item: Grün → Rot → Grau → Leer +
+ 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 + ) + }
); -} \ No newline at end of file +} diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 1b89397..45578c7 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -1,13 +1,8 @@ -// src/components/GamePickerCard.jsx import React from "react"; import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; -export default function GamePickerCard({ - games, - gameId, - setGameId, - onOpenHelp, -}) { +export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) { return (
@@ -21,7 +16,7 @@ export default function GamePickerCard({ > {games.map((g) => ( ))} @@ -30,6 +25,17 @@ export default function GamePickerCard({ Hilfe
+ + {/* 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/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/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/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx new file mode 100644 index 0000000..d77048e --- /dev/null +++ b/frontend/src/components/NewGameModal.jsx @@ -0,0 +1,264 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { styles } from "../styles/styles"; +import { stylesTokens } from "../styles/theme"; + +export default function NewGameModal({ + open, + onClose, + onCreate, + onJoin, + + // ✅ neu: + currentCode = "", + gameFinished = false, + hasGame = false, +}) { + // modes: running | choice | create | join + const [mode, setMode] = useState("choice"); + 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]); + + // ✅ wichtig: beim Öffnen entscheidet der Modus anhand "läuft vs beendet" + useEffect(() => { + if (!open) return; + + setErr(""); + setToast(""); + setJoinCode(""); + setCreated(null); + + // Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen + if (hasGame && !gameFinished) { + setMode("running"); + } else { + setMode("choice"); + } + }, [open, hasGame, gameFinished]); + + 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 copyText = async (text, okMsg = "✅ Code kopiert") => { + try { + await navigator.clipboard.writeText(text || ""); + showToast(okMsg); + } catch { + showToast("❌ Copy nicht möglich"); + } + }; + + const codeToShow = + (created?.code || "").trim() || + (currentCode || "").trim(); + + return ( +
+
e.stopPropagation()}> +
+
+ Spiel +
+ +
+ + {/* Toast */} + {toast && ( +
+ {toast} +
+ )} + +
+ {/* ✅ RUNNING: Nur Code anzeigen, keine Choice */} + {mode === "running" && ( + <> +
+ Das Spiel läuft noch. Hier ist der Join-Code: +
+ +
+
+ Spiel-Code +
+ +
+ {codeToShow || "—"} +
+ + +
+ +
+ 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" && ( + <> +
+ 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/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 7a74545..ab4b407 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -8,19 +8,28 @@ export default function TopBar({ setUserMenuOpen, openPwModal, openDesignModal, + openStatsModal, doLogout, - newGame, + onOpenNewGame, }) { + const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; + return (
Notizbogen
-
{me.email}
+
+ {displayName} +
-
+ + +
+ @@ -63,7 +84,7 @@ export default function TopBar({ )}
-
diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index 64ac143..c4fb1c0 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -1,43 +1,47 @@ -// 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; +/** + * Props: + * - winner: { display_name?: string, email?: string } | null + * - winnerEmail: string | null (legacy fallback) + */ +export default function WinnerBadge({ winner, winnerEmail }) { + const name = + (winner?.display_name || "").trim() || + (winner?.email || "").trim() || + (winnerEmail || "").trim(); + + if (!name) return null; return ( -
-
-
-
🏆 Sieger
-
{w}
-
+
+
+
🏆
-
- Gewonnen +
+ Sieger: + {" "}{name}
+ +
+ festgelegt +
); } diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index e365d0b..b76f588 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -2,30 +2,44 @@ import React from "react"; import { styles } from "../styles/styles"; import { stylesTokens } from "../styles/theme"; -export default function WinnerCard({ value, setValue, onSave }) { +export default function WinnerCard({ + isHost, + members, + winnerUserId, + setWinnerUserId, + onSave, +}) { + if (!isHost) return null; + return (
Sieger
- setValue(e.target.value)} - placeholder="Name des Siegers" + -
-
- Wird pro Spiel lokal gespeichert. +
+ Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 76a1787..19def02 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -5,3 +5,10 @@ import { registerSW } from "virtual:pwa-register"; createRoot(document.getElementById("root")).render(); registerSW({ immediate: true }); +const updateSW = registerSW({ + immediate: true, + onNeedRefresh() { + updateSW(true); // sofort neue Version aktivieren + window.location.reload(); + }, + }); diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js index a7b4deb..c8c7212 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", @@ -120,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: { @@ -153,6 +155,8 @@ export const styles = { // Admin adminWrap: { + position: "relative", + zIndex: 1, marginTop: 14, padding: 12, borderRadius: 16, @@ -184,26 +188,28 @@ export const styles = { // Modal modalOverlay: { position: "fixed", - inset: 0, - background: "rgba(0,0,0,0.65)", + inset: 0, // statt top/left/right/bottom + width: "100%", // ✅ NICHT 100vw + height: "100%", // ✅ NICHT 100vh display: "flex", alignItems: "center", justifyContent: "center", - padding: 16, - zIndex: 9999, - animation: "fadeIn 160ms ease-out", + 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", }, + 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.92), rgba(12,12,14,0.86))", + background: "rgba(12,12,14,0.96)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)", padding: 14, - backdropFilter: "blur(6px)", - animation: "popIn 160ms ease-out", - color: stylesTokens.textMain, + maxHeight: "calc(100vh - 32px)", + overflow: "auto", }, modalHeader: { display: "flex", @@ -471,7 +477,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)", }, diff --git a/frontend/src/styles/themes.js b/frontend/src/styles/themes.js index b71b02d..ff01d9c 100644 --- a/frontend/src/styles/themes.js +++ b/frontend/src/styles/themes.js @@ -65,7 +65,7 @@ export const THEMES = { rowOkText: "#ffd2a8", rowOkBorder: "rgba(255,184,107,0.55)", - rowMaybeBg: "rgba(140, 140, 140, 0.12)", + rowMaybeBg: "rgba(140, 140, 140, 0.04)", rowMaybeText: "rgba(255,210,170,0.85)", rowMaybeBorder: "rgba(255,184,107,0.22)", @@ -103,11 +103,11 @@ export const THEMES = { rowOkText: "rgba(190,255,220,0.92)", rowOkBorder: "rgba(124,255,182,0.55)", - rowMaybeBg: "rgba(120, 255, 190, 0.10)", + rowMaybeBg: "rgba(120, 255, 190, 0.02)", rowMaybeText: "rgba(175,240,210,0.85)", rowMaybeBorder: "rgba(120,255,190,0.22)", - rowEmptyBg: "rgba(255,255,255,0.06)", + rowEmptyBg: "rgba(255,255,255,0.04)", rowEmptyText: "rgba(175,240,210,0.75)", rowEmptyBorder: "rgba(0,0,0,0)", @@ -141,7 +141,7 @@ export const THEMES = { rowOkText: "rgba(210,230,255,0.92)", rowOkBorder: "rgba(143,182,255,0.55)", - rowMaybeBg: "rgba(140, 180, 255, 0.10)", + rowMaybeBg: "rgba(140, 180, 255, 0.04)", rowMaybeText: "rgba(180,205,255,0.85)", rowMaybeBorder: "rgba(143,182,255,0.22)", @@ -179,7 +179,7 @@ export const THEMES = { rowOkText: "rgba(255,240,190,0.92)", rowOkBorder: "rgba(255,226,122,0.55)", - rowMaybeBg: "rgba(255, 226, 122, 0.10)", + rowMaybeBg: "rgba(255, 226, 122, 0.04)", rowMaybeText: "rgba(255,240,180,0.85)", rowMaybeBorder: "rgba(255,226,122,0.22)", diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ef03953..d23be78 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -28,6 +28,9 @@ export default defineConfig({ workbox: { // Caching-Default: die App-Shell wird offline verfügbar globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"], + cleanupOutdatedCaches: true, + skipWaiting: true, + clientsClaim: true, } }) ]