Integrate join codes, player management, and themes
This update introduces "join codes" for games to simplify game joining. Enhancements include player role and winner management for better organization. Additionally, theme preferences are now user-configurable and persisted server-side.
This commit is contained in:
@@ -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,9 +32,71 @@ 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 _auto_migrate(db: Session):
|
||||||
|
"""
|
||||||
|
Very small, pragmatic auto-migration (no alembic).
|
||||||
|
- creates missing tables via create_all
|
||||||
|
- adds missing columns via ALTER TABLE (best effort)
|
||||||
|
"""
|
||||||
|
# Users.theme_key
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# Games.join_code + winner_user_id
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# SheetState.chip_code
|
||||||
|
try:
|
||||||
|
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# Ensure unique index for join_code (best effort)
|
||||||
|
try:
|
||||||
|
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)"))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
# Backfill join_code for existing games
|
||||||
|
games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711
|
||||||
|
if games:
|
||||||
|
used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]])
|
||||||
|
for g in games:
|
||||||
|
code = _rand_join_code()
|
||||||
|
while code in used:
|
||||||
|
code = _rand_join_code()
|
||||||
|
g.join_code = code
|
||||||
|
used.add(code)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Backfill membership: ensure owner is member
|
||||||
|
all_games = db.query(Game).all()
|
||||||
|
for g in all_games:
|
||||||
|
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first()
|
||||||
|
if not exists:
|
||||||
|
db.add(GameMember(game_id=g.id, user_id=g.owner_user_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
def seed_entries(db: Session):
|
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:
|
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"]
|
||||||
@@ -46,14 +116,24 @@ def ensure_admin(db: Session):
|
|||||||
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
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:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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
|
||||||
|
|
||||||
@@ -21,14 +21,39 @@ class User(Base):
|
|||||||
password_hash: Mapped[str] = mapped_column(String)
|
password_hash: Mapped[str] = mapped_column(String)
|
||||||
role: Mapped[str] = mapped_column(String, default=Role.user.value)
|
role: Mapped[str] = mapped_column(String, default=Role.user.value)
|
||||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# UI preferences (persisted server-side)
|
||||||
|
theme_key: Mapped[str] = mapped_column(String, default="default")
|
||||||
|
|
||||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
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()))
|
||||||
|
|
||||||
|
# Creator/owner (for audit), membership controls access
|
||||||
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)
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String)
|
name: Mapped[str] = mapped_column(String)
|
||||||
seed: Mapped[int] = mapped_column(Integer)
|
seed: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
# "Kahoot"-style join code
|
||||||
|
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||||
|
|
||||||
|
# Winner (shared for the game)
|
||||||
|
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
class GameMember(Base):
|
||||||
|
__tablename__ = "game_members"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("game_id", "user_id", name="uq_game_member"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||||
|
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
class Entry(Base):
|
class Entry(Base):
|
||||||
@@ -46,6 +71,10 @@ class SheetState(Base):
|
|||||||
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
|
|
||||||
|
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe
|
||||||
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
||||||
|
|
||||||
|
# Frontend "s.XX" chip selection (persisted)
|
||||||
|
chip_code: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
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"])
|
||||||
|
|
||||||
@@ -10,11 +18,11 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|||||||
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()
|
||||||
password = data.get("password") or ""
|
password = data.get("password") or ""
|
||||||
user = db.query(User).filter(User.email == email, User.disabled == False).first()
|
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
|
||||||
if not user or not verify_password(password, user.password_hash):
|
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):
|
||||||
@@ -29,7 +37,7 @@ 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, "theme_key": user.theme_key}
|
||||||
|
|
||||||
@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)):
|
||||||
@@ -41,7 +49,7 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
|||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="password too short (min 8)")
|
raise HTTPException(status_code=400, detail="password too short (min 8)")
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
|
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="not logged in")
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
|
||||||
@@ -50,3 +58,25 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
|||||||
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)):
|
||||||
|
"""Persist user design selection server-side."""
|
||||||
|
uid = get_session_user_id(req)
|
||||||
|
if not uid:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
|
||||||
|
theme_key = (data.get("theme_key") or "").strip()
|
||||||
|
if not theme_key:
|
||||||
|
raise HTTPException(status_code=400, detail="theme_key required")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
|
||||||
|
user.theme_key = theme_key
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "theme_key": user.theme_key}
|
||||||
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import hashlib, random
|
import hashlib
|
||||||
|
import random
|
||||||
|
import string
|
||||||
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, GameMember, Entry, SheetState, Category, 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) -> str:
|
||||||
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")
|
||||||
@@ -17,30 +20,139 @@ 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()
|
||||||
|
|
||||||
|
def _rand_join_code(n: int = 6) -> str:
|
||||||
|
return "".join(random.choice(string.digits) for _ in range(n))
|
||||||
|
|
||||||
|
def _new_unique_join_code(db: Session) -> str:
|
||||||
|
for _ in range(50):
|
||||||
|
code = _rand_join_code()
|
||||||
|
if not db.query(Game).filter(Game.join_code == code).first():
|
||||||
|
return code
|
||||||
|
raise HTTPException(500, "failed to generate join code")
|
||||||
|
|
||||||
|
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
|
||||||
|
uid = require_user(req, db)
|
||||||
|
g = db.query(Game).filter(Game.id == game_id).first()
|
||||||
|
if not g:
|
||||||
|
raise HTTPException(404, "game not found")
|
||||||
|
m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(403, "not a member of this game")
|
||||||
|
return uid, g
|
||||||
|
|
||||||
@router.post("")
|
@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)
|
join_code = _new_unique_join_code(db)
|
||||||
db.add(g); db.commit()
|
|
||||||
return {"id": g.id, "name": g.name}
|
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# creator becomes member
|
||||||
|
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"id": g.id, "name": g.name, "join_code": g.join_code}
|
||||||
|
|
||||||
|
@router.post("/join")
|
||||||
|
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
|
uid = require_user(req, db)
|
||||||
|
code = (data.get("code") or "").strip()
|
||||||
|
if not code or len(code) < 4:
|
||||||
|
raise HTTPException(400, "code required")
|
||||||
|
|
||||||
|
g = db.query(Game).filter(Game.join_code == code).first()
|
||||||
|
if not g:
|
||||||
|
raise HTTPException(404, "game not found")
|
||||||
|
|
||||||
|
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
||||||
|
if not exists:
|
||||||
|
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}}
|
||||||
|
|
||||||
@router.get("")
|
@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]
|
games = (
|
||||||
|
db.query(Game)
|
||||||
|
.join(GameMember, GameMember.game_id == Game.id)
|
||||||
|
.filter(GameMember.user_id == uid)
|
||||||
|
.order_by(Game.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for g in games:
|
||||||
|
winner = None
|
||||||
|
if g.winner_user_id:
|
||||||
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||||
|
if wu:
|
||||||
|
winner = {"id": wu.id, "email": wu.email}
|
||||||
|
out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@router.get("/{game_id}/meta")
|
||||||
|
def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||||
|
uid, g = require_member(req, db, game_id)
|
||||||
|
|
||||||
|
winner = None
|
||||||
|
if g.winner_user_id:
|
||||||
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||||
|
if wu:
|
||||||
|
winner = {"id": wu.id, "email": wu.email}
|
||||||
|
return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner}
|
||||||
|
|
||||||
|
@router.get("/{game_id}/players")
|
||||||
|
def list_players(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||||
|
_uid, g = require_member(req, db, game_id)
|
||||||
|
|
||||||
|
# only non-admin users (admin doesn't play)
|
||||||
|
players = (
|
||||||
|
db.query(User)
|
||||||
|
.join(GameMember, GameMember.user_id == User.id)
|
||||||
|
.filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712
|
||||||
|
.order_by(User.email.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"id": u.id, "email": u.email} for u in players]
|
||||||
|
|
||||||
|
@router.patch("/{game_id}/winner")
|
||||||
|
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
|
||||||
|
_uid, g = require_member(req, db, game_id)
|
||||||
|
|
||||||
|
winner_user_id = data.get("winner_user_id")
|
||||||
|
if winner_user_id is not None:
|
||||||
|
# must be a member + non-admin
|
||||||
|
u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712
|
||||||
|
if not u or u.role != Role.user.value:
|
||||||
|
raise HTTPException(400, "invalid winner_user_id")
|
||||||
|
|
||||||
|
member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first()
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(400, "winner is not in this game")
|
||||||
|
|
||||||
|
g.winner_user_id = winner_user_id
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "winner_user_id": g.winner_user_id}
|
||||||
|
|
||||||
@router.get("/{game_id}/sheet")
|
@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, g = require_member(req, db, game_id)
|
||||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
|
||||||
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,6 +163,7 @@ 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_code": st.chip_code if st else None,
|
||||||
"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)
|
||||||
@@ -65,19 +178,22 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@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, g = require_member(req, db, game_id)
|
||||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
|
||||||
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_code = data.get("chip_code")
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
|
# chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's')
|
||||||
|
if chip_code is not None:
|
||||||
|
if not isinstance(chip_code, str) or len(chip_code) > 16:
|
||||||
|
raise HTTPException(400, "invalid chip_code")
|
||||||
|
|
||||||
st = db.query(SheetState).filter(
|
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,
|
||||||
@@ -85,13 +201,24 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
|
|||||||
).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_code=None)
|
||||||
db.add(st)
|
db.add(st)
|
||||||
|
|
||||||
if status is not None:
|
if status is not None:
|
||||||
st.status = status
|
st.status = status
|
||||||
|
|
||||||
if "note_tag" in data:
|
if "note_tag" in data:
|
||||||
st.note_tag = note_tag
|
st.note_tag = note_tag
|
||||||
|
# if leaving 's', clear chip
|
||||||
|
if note_tag != "s":
|
||||||
|
st.chip_code = None
|
||||||
|
|
||||||
|
if "chip_code" in data:
|
||||||
|
# chip_code is only meaningful when note_tag is 's'
|
||||||
|
effective_tag = st.note_tag
|
||||||
|
if effective_tag != "s":
|
||||||
|
raise HTTPException(400, "chip_code requires note_tag 's'")
|
||||||
|
st.chip_code = chip_code
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
// src/App.jsx
|
// src/App.jsx
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, 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 { 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, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
|
import { applyTheme, 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 +20,7 @@ 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 JoinGameModal from "./components/JoinGameModal";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useHpGlobalStyles();
|
useHpGlobalStyles();
|
||||||
@@ -39,8 +37,10 @@ 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 / players / winner
|
||||||
const [winnerName, setWinnerName] = useState("");
|
const [gameMeta, setGameMeta] = useState(null);
|
||||||
|
const [players, setPlayers] = useState([]);
|
||||||
|
const [winnerUserId, setWinnerUserId] = useState(null);
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = 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);
|
||||||
|
|
||||||
|
// Join game
|
||||||
|
const [joinOpen, setJoinOpen] = useState(false);
|
||||||
|
|
||||||
|
const currentGame = useMemo(
|
||||||
|
() => games.find((g) => String(g.id) === String(gameId)) || null,
|
||||||
|
[games, gameId]
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Data loaders =====
|
// ===== Data loaders =====
|
||||||
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,6 +89,19 @@ export default function App() {
|
|||||||
setSheet(sh);
|
setSheet(sh);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reloadMeta = async () => {
|
||||||
|
if (!gameId) return;
|
||||||
|
const meta = await api(`/games/${gameId}/meta`);
|
||||||
|
setGameMeta(meta);
|
||||||
|
setWinnerUserId(meta?.winner?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadPlayers = async () => {
|
||||||
|
if (!gameId) return;
|
||||||
|
const ps = await api(`/games/${gameId}/players`);
|
||||||
|
setPlayers(ps);
|
||||||
|
};
|
||||||
|
|
||||||
// ===== Effects =====
|
// ===== Effects =====
|
||||||
|
|
||||||
// Dropdown outside click
|
// Dropdown outside click
|
||||||
@@ -106,19 +126,13 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// load sheet + winner when game changes
|
// load sheet/meta when game changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reloadSheet();
|
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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]);
|
||||||
@@ -138,7 +152,9 @@ export default function App() {
|
|||||||
setGames([]);
|
setGames([]);
|
||||||
setGameId(null);
|
setGameId(null);
|
||||||
setSheet(null);
|
setSheet(null);
|
||||||
setWinnerName("");
|
setGameMeta(null);
|
||||||
|
setPlayers([]);
|
||||||
|
setWinnerUserId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Password change =====
|
// ===== Password change =====
|
||||||
@@ -184,10 +200,19 @@ export default function App() {
|
|||||||
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 }),
|
||||||
|
});
|
||||||
|
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
|
||||||
|
} catch {
|
||||||
|
// ignore; UI already switched
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Game actions =====
|
// ===== Game actions =====
|
||||||
@@ -196,29 +221,34 @@ export default function App() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const gs = await api("/games");
|
const gs = await api("/games");
|
||||||
setGames(gs);
|
setGames(gs);
|
||||||
setGameId(g.id);
|
setGameId(g.id);
|
||||||
|
|
||||||
// Neues Spiel -> Sieger leer
|
|
||||||
clearWinnerLS(g.id);
|
|
||||||
setWinnerName("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Winner actions =====
|
const openJoinModal = () => {
|
||||||
const saveWinner = () => {
|
setJoinOpen(true);
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinGame = async (code) => {
|
||||||
|
const res = await api("/games/join", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
const gs = await api("/games");
|
||||||
|
setGames(gs);
|
||||||
|
setGameId(res?.game?.id || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Winner actions (shared per game) =====
|
||||||
|
const saveWinner = async () => {
|
||||||
if (!gameId) return;
|
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 reloadMeta();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWinnerLS(gameId, v);
|
|
||||||
setWinnerName(v);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Sheet actions =====
|
// ===== Sheet actions =====
|
||||||
@@ -248,8 +278,6 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next === null) clearChipLS(gameId, entry.entry_id);
|
|
||||||
|
|
||||||
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 }),
|
||||||
@@ -265,12 +293,10 @@ export default function App() {
|
|||||||
setChipOpen(false);
|
setChipOpen(false);
|
||||||
setChipEntry(null);
|
setChipEntry(null);
|
||||||
|
|
||||||
setChipLS(gameId, entry.entry_id, chip);
|
|
||||||
|
|
||||||
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_code: chip }),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
@@ -287,8 +313,6 @@ export default function App() {
|
|||||||
setChipOpen(false);
|
setChipOpen(false);
|
||||||
setChipEntry(null);
|
setChipEntry(null);
|
||||||
|
|
||||||
clearChipLS(gameId, entry.entry_id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -303,8 +327,7 @@ export default function App() {
|
|||||||
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);
|
return entry.chip_code ? `s.${entry.chip_code}` : "s";
|
||||||
return chip ? `s.${chip}` : "s";
|
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
};
|
};
|
||||||
@@ -332,6 +355,8 @@ export default function App() {
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const winnerObj = gameMeta?.winner || null;
|
||||||
|
|
||||||
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,6 +370,7 @@ export default function App() {
|
|||||||
setUserMenuOpen={setUserMenuOpen}
|
setUserMenuOpen={setUserMenuOpen}
|
||||||
openPwModal={openPwModal}
|
openPwModal={openPwModal}
|
||||||
openDesignModal={openDesignModal}
|
openDesignModal={openDesignModal}
|
||||||
|
openJoinModal={openJoinModal}
|
||||||
doLogout={doLogout}
|
doLogout={doLogout}
|
||||||
newGame={newGame}
|
newGame={newGame}
|
||||||
/>
|
/>
|
||||||
@@ -355,13 +381,13 @@ export default function App() {
|
|||||||
games={games}
|
games={games}
|
||||||
gameId={gameId}
|
gameId={gameId}
|
||||||
setGameId={setGameId}
|
setGameId={setGameId}
|
||||||
|
joinCode={currentGame?.join_code || ""}
|
||||||
onOpenHelp={() => setHelpOpen(true)}
|
onOpenHelp={() => setHelpOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
{winnerObj && <WinnerBadge winnerEmail={winnerObj.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 +403,13 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sieger ganz unten */}
|
{/* Sieger (shared per Spiel) */}
|
||||||
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} />
|
<WinnerCard
|
||||||
|
players={players}
|
||||||
|
winnerUserId={winnerUserId}
|
||||||
|
setWinnerUserId={setWinnerUserId}
|
||||||
|
onSave={saveWinner}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ height: 24 }} />
|
<div style={{ height: 24 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -399,12 +430,21 @@ export default function App() {
|
|||||||
open={designOpen}
|
open={designOpen}
|
||||||
onClose={() => setDesignOpen(false)}
|
onClose={() => setDesignOpen(false)}
|
||||||
themeKey={themeKey}
|
themeKey={themeKey}
|
||||||
onSelect={(k) => {
|
onSelect={async (k) => {
|
||||||
selectTheme(k);
|
await selectTheme(k);
|
||||||
setDesignOpen(false);
|
setDesignOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<JoinGameModal
|
||||||
|
open={joinOpen}
|
||||||
|
onClose={() => setJoinOpen(false)}
|
||||||
|
onJoin={async (code) => {
|
||||||
|
await joinGame(code);
|
||||||
|
setJoinOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ChipModal
|
<ChipModal
|
||||||
chipOpen={chipOpen}
|
chipOpen={chipOpen}
|
||||||
closeChipModalToDash={closeChipModalToDash}
|
closeChipModalToDash={closeChipModalToDash}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// src/components/GamePickerCard.jsx
|
// 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, joinCode, onOpenHelp }) {
|
||||||
games,
|
|
||||||
gameId,
|
|
||||||
setGameId,
|
|
||||||
onOpenHelp,
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
@@ -30,6 +26,19 @@ export default function GamePickerCard({
|
|||||||
Hilfe
|
Hilfe
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!!joinCode && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0 12px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.85,
|
||||||
|
color: stylesTokens.textDim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Spiel-Code: <b style={{ color: stylesTokens.textGold }}>{joinCode}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
74
frontend/src/components/JoinGameModal.jsx
Normal file
74
frontend/src/components/JoinGameModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,16 +8,19 @@ export default function TopBar({
|
|||||||
setUserMenuOpen,
|
setUserMenuOpen,
|
||||||
openPwModal,
|
openPwModal,
|
||||||
openDesignModal,
|
openDesignModal,
|
||||||
|
openJoinModal,
|
||||||
doLogout,
|
doLogout,
|
||||||
newGame,
|
newGame,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.topBar}>
|
<div style={styles.topBar}>
|
||||||
|
{/* LINKS */}
|
||||||
<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 }}>{me.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* RECHTS */}
|
||||||
<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ü">
|
||||||
@@ -28,13 +31,14 @@ export default function TopBar({
|
|||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && (
|
||||||
<div style={styles.userDropdown}>
|
<div style={styles.userDropdown}>
|
||||||
|
{/* Email Info */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 12px",
|
padding: "10px 12px",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
color: stylesTokens.textDim,
|
color: stylesTokens.textDim,
|
||||||
borderBottom: "1px solid rgba(233,216,166,0.12)",
|
borderBottom: `1px solid ${stylesTokens.goldLine}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{me.email}
|
{me.email}
|
||||||
@@ -44,10 +48,26 @@ export default function TopBar({
|
|||||||
Passwort setzen
|
Passwort setzen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={openDesignModal} style={styles.userDropdownItem}>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
openDesignModal();
|
||||||
|
}}
|
||||||
|
style={styles.userDropdownItem}
|
||||||
|
>
|
||||||
Design ändern
|
Design ändern
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
openJoinModal();
|
||||||
|
}}
|
||||||
|
style={styles.userDropdownItem}
|
||||||
|
>
|
||||||
|
Spiel beitreten
|
||||||
|
</button>
|
||||||
|
|
||||||
<div style={styles.userDropdownDivider} />
|
<div style={styles.userDropdownDivider} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -64,7 +84,7 @@ export default function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={newGame} style={styles.primaryBtn}>
|
<button onClick={newGame} style={styles.primaryBtn}>
|
||||||
✦ New Game
|
✦ Neues Spiel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,27 @@
|
|||||||
// 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 }) {
|
export default function WinnerBadge({ winnerEmail }) {
|
||||||
const w = (winner || "").trim();
|
if (!winnerEmail) return null;
|
||||||
if (!w) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 14 }}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...styles.card,
|
marginTop: 14,
|
||||||
padding: 12,
|
padding: "12px 14px",
|
||||||
display: "flex",
|
borderRadius: 16,
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div>
|
|
||||||
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: 999,
|
|
||||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||||
background: stylesTokens.panelBg,
|
background: stylesTokens.panelBg,
|
||||||
color: stylesTokens.textGold,
|
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
|
||||||
fontWeight: 1000,
|
backdropFilter: "blur(6px)",
|
||||||
whiteSpace: "nowrap",
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Gewonnen
|
<span style={{ fontSize: 16 }}>🏆</span>
|
||||||
</div>
|
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
|
||||||
</div>
|
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
|
// src/components/WinnerCard.jsx
|
||||||
import React from "react";
|
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 }) {
|
/**
|
||||||
|
* props:
|
||||||
|
* - players: [{id,email}]
|
||||||
|
* - winnerUserId: string|null
|
||||||
|
* - setWinnerUserId: fn
|
||||||
|
* - onSave: fn (async ok)
|
||||||
|
*/
|
||||||
|
export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) {
|
||||||
|
const hasPlayers = Array.isArray(players) && players.length > 0;
|
||||||
|
|
||||||
return (
|
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={{ padding: 12, display: "grid", gap: 10 }}>
|
||||||
<input
|
{!hasPlayers ? (
|
||||||
value={value}
|
<div style={{ color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||||
onChange={(e) => setValue(e.target.value)}
|
Keine Spieler gefunden (Admin wird nicht angezeigt).
|
||||||
placeholder="Name des Siegers"
|
</div>
|
||||||
style={{ ...styles.input, flex: 1 }}
|
) : (
|
||||||
onKeyDown={(e) => {
|
<select
|
||||||
if (e.key === "Enter") onSave();
|
value={winnerUserId || ""}
|
||||||
}}
|
onChange={(e) => setWinnerUserId(e.target.value || null)}
|
||||||
/>
|
style={styles.input}
|
||||||
|
>
|
||||||
|
<option value="">— kein Sieger gesetzt —</option>
|
||||||
|
{players.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
<button onClick={onSave} style={styles.primaryBtn} title="Speichern">
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
|
<button onClick={() => setWinnerUserId(null)} style={styles.secondaryBtn}>
|
||||||
|
Leeren
|
||||||
|
</button>
|
||||||
|
<button onClick={onSave} style={styles.primaryBtn} disabled={!hasPlayers}>
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||||
Wird pro Spiel lokal gespeichert.
|
Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user