dev #4
@@ -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,8 +37,8 @@ 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)):
|
||||||
uid = get_session_user_id(req)
|
uid = get_session_user_id(req)
|
||||||
@@ -41,7 +49,7 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
|||||||
if len(password) < 8:
|
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")
|
||||||
|
|
||||||
@@ -49,4 +57,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
|||||||
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)):
|
||||||
|
"""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={{
|
marginTop: 14,
|
||||||
...styles.card,
|
padding: "12px 14px",
|
||||||
padding: 12,
|
borderRadius: 16,
|
||||||
display: "flex",
|
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||||
alignItems: "center",
|
background: stylesTokens.panelBg,
|
||||||
justifyContent: "space-between",
|
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
|
||||||
gap: 10,
|
backdropFilter: "blur(6px)",
|
||||||
}}
|
display: "flex",
|
||||||
>
|
gap: 10,
|
||||||
<div>
|
alignItems: "center",
|
||||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div>
|
}}
|
||||||
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
|
>
|
||||||
</div>
|
<span style={{ fontSize: 16 }}>🏆</span>
|
||||||
|
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
|
||||||
<div
|
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
|
||||||
style={{
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
Speichern
|
<button onClick={() => setWinnerUserId(null)} style={styles.secondaryBtn}>
|
||||||
</button>
|
Leeren
|
||||||
</div>
|
</button>
|
||||||
|
<button onClick={onSave} style={styles.primaryBtn} disabled={!hasPlayers}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</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