dev #4

Merged
nessi merged 25 commits from dev into main 2026-02-06 13:36:47 +00:00
19 changed files with 1531 additions and 233 deletions

View File

@@ -1,9 +1,14 @@
import os import os
import random
import string
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal 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 .security import hash_password
from .routes.auth import router as auth_router from .routes.auth import router as auth_router
from .routes.admin import router as admin_router from .routes.admin import router as admin_router
@@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet")
# Intern: Frontend läuft auf :8081 # Intern: Frontend läuft auf :8081
app.add_middleware( app.add_middleware(
CORSMiddleware, 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_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -24,14 +32,240 @@ app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_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): def seed_entries(db: Session):
# Du kannst hier deine HP-Edition Einträge reinschreiben.
# (Für rein private Nutzung ok öffentlich würde ichs generisch machen.)
if db.query(Entry).count() > 0: if db.query(Entry).count() > 0:
return return
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"] suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"]
items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"] 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"] 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: for s in suspects:
db.add(Entry(category=Category.suspect.value, label=s)) 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.add(Entry(category=Category.location.value, label=l))
db.commit() db.commit()
def ensure_admin(db: Session): def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip() admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first() u = db.query(User).filter(User.email == admin_email).first()
if not u: 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() db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
db = SessionLocal() db = SessionLocal()
try: try:
_auto_migrate(db)
ensure_admin(db) ensure_admin(db)
seed_entries(db) seed_entries(db)
finally: finally:

View File

@@ -1,19 +1,31 @@
# backend/app/models.py
import enum import enum
import uuid import uuid
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column, relationship String,
Boolean,
DateTime,
ForeignKey,
Integer,
SmallInteger,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .db import Base from .db import Base
class Role(str, enum.Enum): class Role(str, enum.Enum):
admin = "admin" admin = "admin"
user = "user" user = "user"
class Category(str, enum.Enum): class Category(str, enum.Enum):
suspect = "suspect" suspect = "suspect"
item = "item" item = "item"
location = "location" location = "location"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 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) disabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 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) name: Mapped[str] = mapped_column(String)
seed: Mapped[int] = mapped_column(Integer) seed: Mapped[int] = mapped_column(Integer)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) 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): class Entry(Base):
__tablename__ = "entries" __tablename__ = "entries"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
category: Mapped[str] = mapped_column(String, index=True) category: Mapped[str] = mapped_column(String, index=True)
label: Mapped[str] = mapped_column(String) label: Mapped[str] = mapped_column(String)
class SheetState(Base): class SheetState(Base):
__tablename__ = "sheet_state" __tablename__ = "sheet_state"
__table_args__ = ( __table_args__ = (UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),)
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())) 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) 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) 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) 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)

View File

@@ -19,21 +19,53 @@ def require_admin(req: Request, db: Session) -> User:
def list_users(req: Request, db: Session = Depends(get_db)): def list_users(req: Request, db: Session = Depends(get_db)):
require_admin(req, db) require_admin(req, db)
users = db.query(User).order_by(User.created_at.desc()).all() 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") @router.post("/users")
def create_user(req: Request, data: dict, db: Session = Depends(get_db)): def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db) require_admin(req, db)
email = (data.get("email") or "").lower().strip() email = (data.get("email") or "").lower().strip()
password = data.get("password") or "" password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or not password: if not email or not password:
raise HTTPException(400, "email/password required") raise HTTPException(400, "email/password required")
if db.query(User).filter(User.email == email).first(): if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists") raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value role = data.get("role") or Role.user.value
if role not in (Role.admin.value, Role.user.value): if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role") 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() db.add(u); db.commit()
return {"ok": True, "id": u.id} return {"ok": True, "id": u.id}
@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}

View File

@@ -2,10 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User 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 = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login") @router.post("/login")
def login(data: dict, resp: Response, db: Session = Depends(get_db)): def login(data: dict, resp: Response, db: Session = Depends(get_db)):
email = (data.get("email") or "").lower().strip() 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() user = db.query(User).filter(User.email == email, User.disabled == False).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials") raise HTTPException(status_code=401, detail="invalid credentials")
set_session(resp, make_session_value(user.id)) 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") @router.post("/logout")
def logout(resp: Response): def logout(resp: Response):
clear_session(resp) clear_session(resp)
return {"ok": True} return {"ok": True}
@router.get("/me") @router.get("/me")
def me(req: Request, db: Session = Depends(get_db)): def me(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req) uid = get_session_user_id(req)
@@ -29,7 +40,44 @@ def me(req: Request, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == uid).first() user = db.query(User).filter(User.id == uid).first()
if not user: if not user:
raise HTTPException(status_code=401, detail="not logged in") 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") @router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)): def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
@@ -48,5 +96,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
user.password_hash = hash_password(password) user.password_hash = hash_password(password)
db.add(user) db.add(user)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@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}

View File

@@ -2,45 +2,204 @@ import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, Entry, SheetState, Category from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"]) router = APIRouter(prefix="/games", tags=["games"])
def require_user(req: Request, db: Session): def require_user(req: Request, db: Session):
uid = get_session_user_id(req) uid = get_session_user_id(req)
if not uid: if not uid:
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return uid return uid
def stable_order(seed: int, user_id: str, entry_id: str) -> str: def stable_order(seed: int, user_id: str, entry_id: str) -> str:
s = f"{seed}:{user_id}:{entry_id}".encode() s = f"{seed}:{user_id}:{entry_id}".encode()
return hashlib.sha256(s).hexdigest() 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("") @router.post("")
def create_game(req: Request, data: dict, db: Session = Depends(get_db)): def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
name = data.get("name") or "Neues Spiel" name = data.get("name") or "Neues Spiel"
seed = random.randint(1, 2_000_000_000) seed = random.randint(1, 2_000_000_000)
g = Game(owner_user_id=uid, name=name, seed=seed)
db.add(g); db.commit() # unique code
return {"id": g.id, "name": g.name} 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("") @router.get("")
def list_games(req: Request, db: Session = Depends(get_db)): def list_games(req: Request, db: Session = Depends(get_db)):
uid = require_user(req, 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") @router.get("/{game_id}/sheet")
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() g = require_game_member(db, game_id, uid)
if not g:
raise HTTPException(404, "game not found")
entries = db.query(Entry).all() 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} state_map = {st.entry_id: st for st in states}
out = {"suspect": [], "item": [], "location": []} out = {"suspect": [], "item": [], "location": []}
@@ -51,11 +210,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
"label": e.label, "label": e.label,
"status": st.status if st else 0, "status": st.status if st else 0,
"note_tag": st.note_tag if st else None, "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), "order": stable_order(g.seed, uid, e.id),
} }
out[e.category].append(item) out[e.category].append(item)
# sort within category
for k in out: for k in out:
out[k].sort(key=lambda x: x["order"]) out[k].sort(key=lambda x: x["order"])
for i in out[k]: for i in out[k]:
@@ -63,29 +222,41 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
return out return out
@router.patch("/{game_id}/sheet/{entry_id}") @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)): def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() g = require_game_member(db, game_id, uid)
if not g:
raise HTTPException(404, "game not found")
status = data.get("status") status = data.get("status")
note_tag = data.get("note_tag") note_tag = data.get("note_tag")
chip = data.get("chip")
if note_tag not in (None, "i", "m", "s"): if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag") raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2, 3): if status is not None and status not in (0, 1, 2, 3):
raise HTTPException(400, "invalid status") raise HTTPException(400, "invalid status")
st = db.query(SheetState).filter( 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.game_id == g.id,
SheetState.owner_user_id == uid, SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id SheetState.entry_id == entry_id,
).first() )
.first()
)
if not st: 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) db.add(st)
if status is not None: 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: if "note_tag" in data:
st.note_tag = note_tag 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() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -1,16 +1,12 @@
// src/App.jsx
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { api } from "./api/client"; import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag"; import { cycleTag } from "./utils/cycleTag";
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles"; import { styles } from "./styles/styles";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
import AdminPanel from "./components/AdminPanel"; import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage"; import LoginPage from "./components/LoginPage";
@@ -23,6 +19,8 @@ import SheetSection from "./components/SheetSection";
import DesignModal from "./components/DesignModal"; import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard"; import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge"; import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
@@ -39,15 +37,17 @@ export default function App() {
const [sheet, setSheet] = useState(null); const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null); const [pulseId, setPulseId] = useState(null);
// Winner (per game) // Game meta
const [winnerName, setWinnerName] = useState(""); 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 // Modals
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [chipOpen, setChipOpen] = useState(false); const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null); const [chipEntry, setChipEntry] = useState(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false);
@@ -60,13 +60,20 @@ export default function App() {
const [designOpen, setDesignOpen] = useState(false); const [designOpen, setDesignOpen] = useState(false);
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY); 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 load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
// Theme pro User laden & anwenden const tk = m?.theme_key || DEFAULT_THEME_KEY;
const tk = loadThemeKey(m?.email);
setThemeKey(tk); setThemeKey(tk);
applyTheme(tk); applyTheme(tk);
@@ -82,7 +89,15 @@ export default function App() {
setSheet(sh); 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 // Dropdown outside click
useEffect(() => { useEffect(() => {
@@ -94,35 +109,58 @@ export default function App() {
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [userMenuOpen]); }, [userMenuOpen]);
// initial load (try session) // initial load
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
await load(); await load();
} catch { } catch {}
// not logged in
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// load sheet + winner when game changes // on game change
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!gameId) return; if (!gameId) return;
try { try {
await reloadSheet(); await reloadSheet();
} catch { await loadGameMeta();
// ignore } catch {}
}
// Sieger pro Game aus localStorage laden
setWinnerName(getWinnerLS(gameId));
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
// ✅ Live refresh (Members/Meta) damit neue Joiner ohne Reload sichtbar sind
// Für 56 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 ===== // ===== Auth actions =====
const doLogin = async () => { const doLogin = async () => {
await api("/auth/login", { await api("/auth/login", {
@@ -138,10 +176,12 @@ export default function App() {
setGames([]); setGames([]);
setGameId(null); setGameId(null);
setSheet(null); setSheet(null);
setWinnerName(""); setGameMeta(null);
setMembers([]);
setWinnerUserId("");
}; };
// ===== Password change ===== // ===== Password =====
const openPwModal = () => { const openPwModal = () => {
setPwMsg(""); setPwMsg("");
setPw1(""); setPw1("");
@@ -178,20 +218,60 @@ export default function App() {
} }
}; };
// ===== Theme actions ===== // ===== Theme =====
const openDesignModal = () => { const openDesignModal = () => {
setDesignOpen(true); setDesignOpen(true);
setUserMenuOpen(false); setUserMenuOpen(false);
}; };
const selectTheme = (key) => { const selectTheme = async (key) => {
setThemeKey(key); setThemeKey(key);
applyTheme(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 ===== // ===== Stats (always fresh on open) =====
const newGame = async () => { 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", { const g = await api("/games", {
method: "POST", method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
@@ -199,26 +279,32 @@ export default function App() {
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
// ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect)
setGameId(g.id); setGameId(g.id);
// Neues Spiel -> Sieger leer return g; // includes code
clearWinnerLS(g.id);
setWinnerName("");
}; };
// ===== Winner actions ===== const joinGame = async (code) => {
const saveWinner = () => { 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; if (!gameId) return;
const v = (winnerName || "").trim(); await api(`/games/${gameId}/winner`, {
method: "PATCH",
if (!v) { body: JSON.stringify({ winner_user_id: winnerUserId || null }),
clearWinnerLS(gameId); });
setWinnerName(""); await loadGameMeta();
return;
}
setWinnerLS(gameId, v);
setWinnerName(v);
}; };
// ===== Sheet actions ===== // ===== Sheet actions =====
@@ -252,7 +338,7 @@ export default function App() {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ note_tag: next }), body: JSON.stringify({ note_tag: next, chip: null }),
}); });
await reloadSheet(); await reloadSheet();
@@ -270,7 +356,7 @@ export default function App() {
try { try {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ note_tag: "s" }), body: JSON.stringify({ note_tag: "s", chip }),
}); });
} finally { } finally {
await reloadSheet(); await reloadSheet();
@@ -292,7 +378,7 @@ export default function App() {
try { try {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ note_tag: null }), body: JSON.stringify({ note_tag: null, chip: null }),
}); });
} finally { } finally {
await reloadSheet(); await reloadSheet();
@@ -302,11 +388,14 @@ export default function App() {
const displayTag = (entry) => { const displayTag = (entry) => {
const t = entry.note_tag; const t = entry.note_tag;
if (!t) return "—"; if (!t) return "—";
if (t === "s") { 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 chip ? `s.${chip}` : "s";
} }
return t;
return t; // i oder m
}; };
// ===== Login page ===== // ===== 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 ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={styles.bgFixed} aria-hidden="true"> <div style={styles.bgFixed} aria-hidden="true">
@@ -345,8 +436,9 @@ export default function App() {
setUserMenuOpen={setUserMenuOpen} setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal} openPwModal={openPwModal}
openDesignModal={openDesignModal} openDesignModal={openDesignModal}
openStatsModal={openStatsModal}
doLogout={doLogout} doLogout={doLogout}
newGame={newGame} onOpenNewGame={() => setNewGameOpen(true)}
/> />
{me.role === "admin" && <AdminPanel />} {me.role === "admin" && <AdminPanel />}
@@ -358,10 +450,15 @@ export default function App() {
onOpenHelp={() => setHelpOpen(true)} onOpenHelp={() => setHelpOpen(true)}
/> />
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} /> {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
<WinnerBadge
winner={{
display_name: gameMeta?.winner_display_name || "",
email: gameMeta?.winner_email || "",
}}
/>
{/* Sieger Badge: nur wenn gesetzt */} <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<WinnerBadge winner={(winnerName || "").trim()} />
<div style={{ marginTop: 14, display: "grid", gap: 14 }}> <div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => ( {sections.map((sec) => (
@@ -377,8 +474,14 @@ export default function App() {
))} ))}
</div> </div>
{/* Sieger ganz unten */} {/* Host-only Winner Auswahl */}
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} /> <WinnerCard
isHost={isHost}
members={members}
winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId}
onSave={saveWinner}
/>
<div style={{ height: 24 }} /> <div style={{ height: 24 }} />
</div> </div>
@@ -405,11 +508,30 @@ export default function App() {
}} }}
/> />
<NewGameModal
open={newGameOpen}
onClose={() => setNewGameOpen(false)}
onCreate={createGame}
onJoin={joinGame}
currentCode={gameMeta?.code || ""}
gameFinished={!!gameMeta?.winner_user_id}
hasGame={!!gameId}
/>
<ChipModal <ChipModal
chipOpen={chipOpen} chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash} closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip} chooseChip={chooseChip}
/> />
<StatsModal
open={statsOpen}
onClose={closeStatsModal}
me={me}
stats={stats}
loading={statsLoading}
error={statsError}
/>
</div> </div>
); );
} }

View File

@@ -2,16 +2,29 @@ import React, { useEffect, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { createPortal } from "react-dom";
export default function AdminPanel() { export default function AdminPanel() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [role, setRole] = useState("user"); const [role, setRole] = useState("user");
const [msg, setMsg] = useState(""); 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 loadUsers = async () => {
const u = await api("/admin/users"); const u = await api("/admin/users");
setUsers(u); setUsers(u);
@@ -22,6 +35,7 @@ export default function AdminPanel() {
}, []); }, []);
const resetForm = () => { const resetForm = () => {
setDisplayName("");
setEmail(""); setEmail("");
setPassword(""); setPassword("");
setRole("user"); setRole("user");
@@ -32,7 +46,7 @@ export default function AdminPanel() {
try { try {
await api("/admin/users", { await api("/admin/users", {
method: "POST", method: "POST",
body: JSON.stringify({ email, password, role }), body: JSON.stringify({ display_name: displayName, email, password, role }),
}); });
setMsg("✅ User erstellt."); setMsg("✅ User erstellt.");
await loadUsers(); 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 = () => { const closeModal = () => {
setOpen(false); setOpen(false);
setMsg(""); setMsg("");
@@ -63,19 +87,45 @@ export default function AdminPanel() {
<div style={{ marginTop: 8, display: "grid", gap: 8 }}> <div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => ( {users.map((u) => (
<div key={u.id} style={styles.userRow}> <div
<div style={{ color: stylesTokens.textMain }}>{u.email}</div> key={u.id}
style={{
...styles.userRow,
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
alignItems: "center",
}}
>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role} {u.role}
</div> </div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}> <div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"} {u.disabled ? "disabled" : "active"}
</div> </div>
<button
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button>
</div> </div>
))} ))}
</div> </div>
{open && ( {open &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}> <div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
@@ -87,14 +137,29 @@ export default function AdminPanel() {
</button> </button>
</div> </div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}> <div
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Name (z.B. Sascha)"
style={styles.input}
autoFocus
/>
<input <input
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="Email" placeholder="Email"
style={styles.input} style={styles.input}
autoFocus
/> />
<input <input
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -102,6 +167,7 @@ export default function AdminPanel() {
type="password" type="password"
style={styles.input} style={styles.input}
/> />
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}> <select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
@@ -125,12 +191,14 @@ export default function AdminPanel() {
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Klick auf Item: Grün Rot Grau Leer Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div> </div>
</div> </div>
</div> </div>
</div> </div>,
)} document.body
)
}
</div> </div>
); );
} }

View File

@@ -1,13 +1,8 @@
// src/components/GamePickerCard.jsx
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function GamePickerCard({ export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) {
games,
gameId,
setGameId,
onOpenHelp,
}) {
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
@@ -21,7 +16,7 @@ export default function GamePickerCard({
> >
{games.map((g) => ( {games.map((g) => (
<option key={g.id} value={g.id}> <option key={g.id} value={g.id}>
{g.name} {g.name} {g.code ? `${g.code}` : ""}
</option> </option>
))} ))}
</select> </select>
@@ -30,6 +25,17 @@ export default function GamePickerCard({
Hilfe Hilfe
</button> </button>
</div> </div>
{/* kleine Code Zeile unter dem Picker (optional nice) */}
{(() => {
const cur = games.find((x) => x.id === gameId);
if (!cur?.code) return null;
return (
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
</div>
);
})()}
</div> </div>
</div> </div>
); );

View File

@@ -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 (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="z.B. 123456"
style={styles.input}
inputMode="numeric"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") doJoin();
}}
/>
{msg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={onClose} style={styles.secondaryBtn} disabled={busy}>
Abbrechen
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={busy}>
{busy ? "Beitreten..." : "Beitreten"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Der Spiel-Code steht beim Host unter dem Spiel-Dropdown.
</div>
</div>
</div>
</div>
);
}

View File

@@ -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(
<div
style={styles.modalOverlay}
onMouseDown={(e) => {
// Klick außerhalb schließt
if (e.target === e.currentTarget) onClose?.();
}}
>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}

View File

@@ -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 (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Spiel
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
{/* Toast */}
{toast && (
<div
style={{
marginTop: 10,
padding: "10px 12px",
borderRadius: 12,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textMain,
fontWeight: 900,
textAlign: "center",
animation: "fadeIn 120ms ease-out",
}}
>
{toast}
</div>
)}
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
{/* ✅ RUNNING: Nur Code anzeigen, keine Choice */}
{mode === "running" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:
</div>
<div
style={{
display: "grid",
gap: 8,
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
textAlign: "center",
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
</div>
<div
style={{
fontSize: 28,
fontWeight: 1100,
letterSpacing: 2,
color: stylesTokens.textGold,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
}}
>
{codeToShow || "—"}
</div>
<button
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
disabled={!codeToShow}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"}
>
Code kopieren
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten.
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
</button>
</div>
</>
)}
{/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */}
{mode === "choice" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?
</div>
<button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen
</button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten
</button>
</>
)}
{mode === "join" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Gib den <b>Code</b> ein:
</div>
<input
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
placeholder="z.B. 8K3MZQ"
style={styles.input}
autoFocus
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten
</button>
</div>
</>
)}
{mode === "create" && created && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei Alte Spiele sichtbar:
</div>
<div
style={{
display: "grid",
gap: 8,
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
textAlign: "center",
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
</div>
<div
style={{
fontSize: 28,
fontWeight: 1100,
letterSpacing: 2,
color: stylesTokens.textGold,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
}}
>
{created.code}
</div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren
</button>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
</button>
</div>
</>
)}
{err && <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>{err}</div>}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div
style={{
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.16)`,
background: "rgba(10,10,12,0.55)",
padding: 12,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
}}
>
<div
style={{
fontSize: 11,
opacity: 0.8,
color: stylesTokens.textDim,
letterSpacing: 0.6,
textTransform: "uppercase",
}}
>
{label}
</div>
<div
style={{
marginTop: 6,
fontWeight: 1000,
fontSize: 26,
lineHeight: "30px",
color: stylesTokens.textGold,
}}
>
{value}
</div>
{sub ? (
<div style={{ marginTop: 2, fontSize: 12, opacity: 0.85, color: stylesTokens.textDim }}>
{sub}
</div>
) : null}
</div>
);
}
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(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12 }}>
{loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
}}
>
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
</div>
)}
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Gespielt zählt nur Spiele mit gesetztem Sieger.
</div>
</div>
</div>,
document.body
);
}

View File

@@ -8,19 +8,28 @@ export default function TopBar({
setUserMenuOpen, setUserMenuOpen,
openPwModal, openPwModal,
openDesignModal, openDesignModal,
openStatsModal,
doLogout, doLogout,
newGame, onOpenNewGame,
}) { }) {
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return ( return (
<div style={styles.topBar}> <div style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu> <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü"> <button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16 }}>👤</span> <span style={{ fontSize: 16 }}>👤</span>
<span>User</span> <span>User</span>
<span style={{ opacity: 0.7 }}></span> <span style={{ opacity: 0.7 }}></span>
@@ -37,9 +46,21 @@ export default function TopBar({
borderBottom: "1px solid rgba(233,216,166,0.12)", borderBottom: "1px solid rgba(233,216,166,0.12)",
}} }}
> >
{me.email} {me?.email || ""}
</div> </div>
<button
onClick={() => {
setUserMenuOpen(false);
openStatsModal?.();
}}
style={styles.userDropdownItem}
>
Statistik
</button>
<div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen Passwort setzen
</button> </button>
@@ -63,7 +84,7 @@ export default function TopBar({
)} )}
</div> </div>
<button onClick={newGame} style={styles.primaryBtn}> <button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game New Game
</button> </button>
</div> </div>

View File

@@ -1,42 +1,46 @@
// src/components/WinnerBadge.jsx
import React from "react"; import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
export default function WinnerBadge({ winner }) { /**
const w = (winner || "").trim(); * Props:
if (!w) return null; * - 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 ( return (
<div style={{ marginTop: 14 }}>
<div <div
style={{ style={{
...styles.card, marginTop: 14,
padding: 12, padding: "10px 12px",
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(6px)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: 10, gap: 10,
}} }}
> >
<div> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div> <div style={{ fontSize: 18 }}>🏆</div>
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger:
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div>
</div> </div>
<div <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
style={{ festgelegt
padding: "8px 12px",
borderRadius: 999,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textGold,
fontWeight: 1000,
whiteSpace: "nowrap",
}}
>
Gewonnen
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,30 +2,44 @@ import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; 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 ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div> <div style={styles.sectionHeader}>Sieger</div>
<div style={styles.cardBody}> <div style={styles.cardBody}>
<input <select
value={value} value={winnerUserId || ""}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setWinnerUserId(e.target.value || "")}
placeholder="Name des Siegers"
style={{ ...styles.input, flex: 1 }} style={{ ...styles.input, flex: 1 }}
onKeyDown={(e) => { >
if (e.key === "Enter") onSave(); <option value=""> kein Sieger </option>
}} {members.map((m) => {
/> const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
<option key={m.id} value={m.id}>
{dn}
</option>
);
})}
</select>
<button onClick={onSave} style={styles.primaryBtn} title="Speichern"> <button onClick={onSave} style={styles.primaryBtn}>
Speichern Speichern
</button> </button>
</div> </div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim }}> <div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Wird pro Spiel lokal gespeichert. Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,3 +5,10 @@ import { registerSW } from "virtual:pwa-register";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);
registerSW({ immediate: true }); registerSW({ immediate: true });
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
updateSW(true); // sofort neue Version aktivieren
window.location.reload();
},
});

View File

@@ -16,6 +16,8 @@ export const styles = {
}, },
topBar: { topBar: {
position: "relative",
zIndex: 50,
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
@@ -120,13 +122,13 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: 10, padding: "10px 12px",
borderRadius: 12, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.55)", background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
@@ -153,6 +155,8 @@ export const styles = {
// Admin // Admin
adminWrap: { adminWrap: {
position: "relative",
zIndex: 1,
marginTop: 14, marginTop: 14,
padding: 12, padding: 12,
borderRadius: 16, borderRadius: 16,
@@ -184,26 +188,28 @@ export const styles = {
// Modal // Modal
modalOverlay: { modalOverlay: {
position: "fixed", position: "fixed",
inset: 0, inset: 0, // statt top/left/right/bottom
background: "rgba(0,0,0,0.65)", width: "100%", // ✅ NICHT 100vw
height: "100%", // ✅ NICHT 100vh
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: 16, 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))",
zIndex: 9999, boxSizing: "border-box", // wichtig bei padding
animation: "fadeIn 160ms ease-out", zIndex: 2147483647,
background: "rgba(0,0,0,0.72)",
overflowY: "auto",
}, },
modalCard: { modalCard: {
width: "100%", width: "min(560px, 100%)",
maxWidth: 560,
borderRadius: 18, borderRadius: 18,
border: `1px solid rgba(233,216,166,0.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)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 14, padding: 14,
backdropFilter: "blur(6px)", maxHeight: "calc(100vh - 32px)",
animation: "popIn 160ms ease-out", overflow: "auto",
color: stylesTokens.textMain,
}, },
modalHeader: { modalHeader: {
display: "flex", 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))", 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)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden", overflow: "hidden",
zIndex: 10000, zIndex: 99999,
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
}, },

View File

@@ -65,7 +65,7 @@ export const THEMES = {
rowOkText: "#ffd2a8", rowOkText: "#ffd2a8",
rowOkBorder: "rgba(255,184,107,0.55)", 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)", rowMaybeText: "rgba(255,210,170,0.85)",
rowMaybeBorder: "rgba(255,184,107,0.22)", rowMaybeBorder: "rgba(255,184,107,0.22)",
@@ -103,11 +103,11 @@ export const THEMES = {
rowOkText: "rgba(190,255,220,0.92)", rowOkText: "rgba(190,255,220,0.92)",
rowOkBorder: "rgba(124,255,182,0.55)", 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)", rowMaybeText: "rgba(175,240,210,0.85)",
rowMaybeBorder: "rgba(120,255,190,0.22)", 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)", rowEmptyText: "rgba(175,240,210,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)", rowEmptyBorder: "rgba(0,0,0,0)",
@@ -141,7 +141,7 @@ export const THEMES = {
rowOkText: "rgba(210,230,255,0.92)", rowOkText: "rgba(210,230,255,0.92)",
rowOkBorder: "rgba(143,182,255,0.55)", 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)", rowMaybeText: "rgba(180,205,255,0.85)",
rowMaybeBorder: "rgba(143,182,255,0.22)", rowMaybeBorder: "rgba(143,182,255,0.22)",
@@ -179,7 +179,7 @@ export const THEMES = {
rowOkText: "rgba(255,240,190,0.92)", rowOkText: "rgba(255,240,190,0.92)",
rowOkBorder: "rgba(255,226,122,0.55)", 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)", rowMaybeText: "rgba(255,240,180,0.85)",
rowMaybeBorder: "rgba(255,226,122,0.22)", rowMaybeBorder: "rgba(255,226,122,0.22)",

View File

@@ -28,6 +28,9 @@ export default defineConfig({
workbox: { workbox: {
// Caching-Default: die App-Shell wird offline verfügbar // Caching-Default: die App-Shell wird offline verfügbar
globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"], globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"],
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
} }
}) })
] ]