dev #4
@@ -1,19 +1,30 @@
|
||||
import enum
|
||||
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
|
||||
from sqlalchemy.sql import func
|
||||
from .db import Base
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
|
||||
class Category(str, enum.Enum):
|
||||
suspect = "suspect"
|
||||
item = "item"
|
||||
location = "location"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
@@ -21,40 +32,39 @@ class User(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String)
|
||||
role: Mapped[str] = mapped_column(String, default=Role.user.value)
|
||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# UI preferences (persisted server-side)
|
||||
# NEW: Theme im Userprofil (damit es auf anderen Geräten mitkommt)
|
||||
theme_key: Mapped[str] = mapped_column(String, default="default")
|
||||
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Game(Base):
|
||||
__tablename__ = "games"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Creator/owner (for audit), membership controls access
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
# NEW: Host (nur Host darf Winner setzen)
|
||||
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
seed: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# "Kahoot"-style join code
|
||||
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
# NEW: Join-Code (Kahoot-Style)
|
||||
code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
|
||||
# Winner (shared for the game)
|
||||
# NEW: Winner (aus Users, nicht Freitext)
|
||||
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"),
|
||||
)
|
||||
__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())
|
||||
joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
@@ -62,11 +72,13 @@ class Entry(Base):
|
||||
category: Mapped[str] = mapped_column(String, index=True)
|
||||
label: Mapped[str] = mapped_column(String)
|
||||
|
||||
|
||||
class SheetState(Base):
|
||||
__tablename__ = "sheet_state"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
@@ -75,6 +87,6 @@ class SheetState(Base):
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe
|
||||
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
||||
|
||||
# Frontend "s.XX" chip selection (persisted)
|
||||
chip_code: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# NEW: Chip persistieren (statt LocalStorage)
|
||||
chip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User
|
||||
from ..security import (
|
||||
@@ -14,21 +13,25 @@ from ..security import (
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
|
||||
email = (data.get("email") or "").lower().strip()
|
||||
password = data.get("password") or ""
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first()
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
|
||||
set_session(resp, make_session_value(user.id))
|
||||
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(resp: Response):
|
||||
clear_session(resp)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(req: Request, db: Session = Depends(get_db)):
|
||||
uid = get_session_user_id(req)
|
||||
@@ -39,6 +42,7 @@ def me(req: Request, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key}
|
||||
|
||||
|
||||
@router.patch("/password")
|
||||
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
uid = get_session_user_id(req)
|
||||
@@ -49,19 +53,18 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="password too short (min 8)")
|
||||
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
user.password_hash = hash_password(password)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
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")
|
||||
@@ -70,7 +73,7 @@ def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
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
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
|
||||
@@ -1,151 +1,193 @@
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
import hashlib, random
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
|
||||
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
|
||||
from ..security import get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/games", tags=["games"])
|
||||
|
||||
def require_user(req: Request, db: Session) -> str:
|
||||
|
||||
def require_user(req: Request, db: Session):
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return uid
|
||||
|
||||
|
||||
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
||||
s = f"{seed}:{user_id}:{entry_id}".encode()
|
||||
return hashlib.sha256(s).hexdigest()
|
||||
|
||||
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")
|
||||
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
|
||||
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
|
||||
uid = require_user(req, db)
|
||||
|
||||
def gen_code(n=6) -> str:
|
||||
return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
|
||||
|
||||
|
||||
def ensure_member(db: Session, game_id: str, user_id: str):
|
||||
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
|
||||
if ex:
|
||||
return
|
||||
db.add(GameMember(game_id=game_id, user_id=user_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def require_game_member(db: Session, game_id: str, user_id: str) -> Game:
|
||||
g = db.query(Game).filter(Game.id == game_id).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
||||
if not m:
|
||||
|
||||
mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
|
||||
if not mem:
|
||||
raise HTTPException(403, "not a member of this game")
|
||||
return uid, g
|
||||
return g
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
|
||||
name = data.get("name") or "Neues Spiel"
|
||||
seed = random.randint(1, 2_000_000_000)
|
||||
join_code = _new_unique_join_code(db)
|
||||
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
|
||||
# unique code
|
||||
code = gen_code()
|
||||
while db.query(Game).filter(Game.code == code).first():
|
||||
code = gen_code()
|
||||
|
||||
g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
|
||||
# creator becomes member
|
||||
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||
db.commit()
|
||||
# creator joins automatically
|
||||
ensure_member(db, g.id, uid)
|
||||
|
||||
return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
|
||||
|
||||
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:
|
||||
code = (data.get("code") or "").strip().upper()
|
||||
if not code:
|
||||
raise HTTPException(400, "code required")
|
||||
|
||||
g = db.query(Game).filter(Game.join_code == code).first()
|
||||
g = db.query(Game).filter(Game.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()
|
||||
ensure_member(db, g.id, uid)
|
||||
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
|
||||
|
||||
return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}}
|
||||
|
||||
@router.get("")
|
||||
def list_games(req: Request, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
|
||||
games = (
|
||||
# list games where user is member
|
||||
q = (
|
||||
db.query(Game)
|
||||
.join(GameMember, GameMember.game_id == Game.id)
|
||||
.filter(GameMember.user_id == uid)
|
||||
.order_by(Game.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
games = q.all()
|
||||
|
||||
# winner email (optional)
|
||||
out = []
|
||||
for g in games:
|
||||
winner = None
|
||||
winner_email = 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})
|
||||
winner_email = wu.email if wu else None
|
||||
out.append(
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"seed": g.seed,
|
||||
"code": g.code,
|
||||
"host_user_id": g.host_user_id,
|
||||
"winner_user_id": g.winner_user_id,
|
||||
"winner_email": winner_email,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@router.get("/{game_id}/meta")
|
||||
def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
winner = None
|
||||
@router.get("/{game_id}")
|
||||
def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = require_game_member(db, game_id, uid)
|
||||
|
||||
winner_email = None
|
||||
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}
|
||||
winner_email = wu.email if wu else None
|
||||
|
||||
@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)
|
||||
return {
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"code": g.code,
|
||||
"host_user_id": g.host_user_id,
|
||||
"winner_user_id": g.winner_user_id,
|
||||
"winner_email": winner_email,
|
||||
}
|
||||
|
||||
# only non-admin users (admin doesn't play)
|
||||
players = (
|
||||
|
||||
@router.get("/{game_id}/members")
|
||||
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
_g = require_game_member(db, game_id, uid)
|
||||
|
||||
# return only "user" role (admin excluded)
|
||||
members = (
|
||||
db.query(User)
|
||||
.join(GameMember, GameMember.user_id == User.id)
|
||||
.filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712
|
||||
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
|
||||
.order_by(User.email.asc())
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "email": u.email} for u in players]
|
||||
return [{"id": u.id, "email": u.email} for u in members]
|
||||
|
||||
|
||||
@router.patch("/{game_id}/winner")
|
||||
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
_uid, g = require_member(req, db, game_id)
|
||||
uid = require_user(req, db)
|
||||
g = require_game_member(db, game_id, uid)
|
||||
|
||||
# only host can set winner
|
||||
if g.host_user_id != uid:
|
||||
raise HTTPException(403, "only host can set winner")
|
||||
|
||||
winner_user_id = data.get("winner_user_id")
|
||||
if winner_user_id is 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")
|
||||
if winner_user_id is None:
|
||||
g.winner_user_id = None
|
||||
db.add(g)
|
||||
db.commit()
|
||||
return {"ok": True, "winner_user_id": None}
|
||||
|
||||
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")
|
||||
# must be a member AND role=user
|
||||
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
|
||||
if not member:
|
||||
raise HTTPException(400, "winner must be a member of the game")
|
||||
|
||||
u = db.query(User).filter(User.id == winner_user_id).first()
|
||||
if not u or u.role != Role.user.value or u.disabled:
|
||||
raise HTTPException(400, "invalid winner")
|
||||
|
||||
g.winner_user_id = winner_user_id
|
||||
db.add(g)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "winner_user_id": g.winner_user_id}
|
||||
|
||||
|
||||
@router.get("/{game_id}/sheet")
|
||||
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid, g = require_member(req, db, game_id)
|
||||
uid = require_user(req, db)
|
||||
g = require_game_member(db, game_id, uid)
|
||||
|
||||
entries = db.query(Entry).all()
|
||||
states = (
|
||||
@@ -163,12 +205,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
"label": e.label,
|
||||
"status": st.status if st else 0,
|
||||
"note_tag": st.note_tag if st else None,
|
||||
"chip_code": st.chip_code if st else None,
|
||||
"chip": st.chip if st else None, # NEW
|
||||
"order": stable_order(g.seed, uid, e.id),
|
||||
}
|
||||
out[e.category].append(item)
|
||||
|
||||
# sort within category
|
||||
for k in out:
|
||||
out[k].sort(key=lambda x: x["order"])
|
||||
for i in out[k]:
|
||||
@@ -176,49 +217,58 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{game_id}/sheet/{entry_id}")
|
||||
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
uid, g = require_member(req, db, game_id)
|
||||
uid = require_user(req, db)
|
||||
g = require_game_member(db, game_id, uid)
|
||||
|
||||
status = data.get("status")
|
||||
note_tag = data.get("note_tag")
|
||||
chip_code = data.get("chip_code")
|
||||
chip = data.get("chip")
|
||||
|
||||
if note_tag not in (None, "i", "m", "s"):
|
||||
raise HTTPException(400, "invalid note_tag")
|
||||
if status is not None and status not in (0, 1, 2, 3):
|
||||
raise HTTPException(400, "invalid status")
|
||||
|
||||
# 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")
|
||||
if chip is not None:
|
||||
chip = (chip or "").strip().upper()
|
||||
if chip == "":
|
||||
chip = None
|
||||
if chip is not None:
|
||||
if len(chip) > 8:
|
||||
raise HTTPException(400, "invalid chip")
|
||||
|
||||
st = db.query(SheetState).filter(
|
||||
SheetState.game_id == g.id,
|
||||
SheetState.owner_user_id == uid,
|
||||
SheetState.entry_id == entry_id
|
||||
).first()
|
||||
st = (
|
||||
db.query(SheetState)
|
||||
.filter(
|
||||
SheetState.game_id == g.id,
|
||||
SheetState.owner_user_id == uid,
|
||||
SheetState.entry_id == entry_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not st:
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None)
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None)
|
||||
db.add(st)
|
||||
|
||||
if status is not None:
|
||||
st.status = status
|
||||
|
||||
if "note_tag" in data:
|
||||
st.note_tag = note_tag
|
||||
# if leaving 's', clear chip
|
||||
if note_tag != "s":
|
||||
st.chip_code = None
|
||||
|
||||
if "chip_code" in data:
|
||||
# chip_code is only meaningful when note_tag is 's'
|
||||
effective_tag = st.note_tag
|
||||
if effective_tag != "s":
|
||||
raise HTTPException(400, "chip_code requires note_tag 's'")
|
||||
st.chip_code = chip_code
|
||||
# wenn note_tag zurück auf null -> chip auch löschen
|
||||
if note_tag is None:
|
||||
st.chip = None
|
||||
|
||||
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
|
||||
if "chip" in data:
|
||||
if st.note_tag == "s":
|
||||
st.chip = chip
|
||||
else:
|
||||
st.chip = None
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// src/App.jsx
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "./api/client";
|
||||
import { cycleTag } from "./utils/cycleTag";
|
||||
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
|
||||
|
||||
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
|
||||
import { styles } from "./styles/styles";
|
||||
|
||||
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
|
||||
|
||||
import AdminPanel from "./components/AdminPanel";
|
||||
@@ -20,7 +19,7 @@ import SheetSection from "./components/SheetSection";
|
||||
import DesignModal from "./components/DesignModal";
|
||||
import WinnerCard from "./components/WinnerCard";
|
||||
import WinnerBadge from "./components/WinnerBadge";
|
||||
import JoinGameModal from "./components/JoinGameModal";
|
||||
import NewGameModal from "./components/NewGameModal";
|
||||
|
||||
export default function App() {
|
||||
useHpGlobalStyles();
|
||||
@@ -37,17 +36,17 @@ export default function App() {
|
||||
const [sheet, setSheet] = useState(null);
|
||||
const [pulseId, setPulseId] = useState(null);
|
||||
|
||||
// Game meta / players / winner
|
||||
const [gameMeta, setGameMeta] = useState(null);
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [winnerUserId, setWinnerUserId] = useState(null);
|
||||
// Game meta
|
||||
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
// Winner selection (host only)
|
||||
const [winnerUserId, setWinnerUserId] = useState("");
|
||||
|
||||
// Modals
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const [chipOpen, setChipOpen] = useState(false);
|
||||
const [chipEntry, setChipEntry] = useState(null);
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
@@ -60,15 +59,9 @@ export default function App() {
|
||||
const [designOpen, setDesignOpen] = useState(false);
|
||||
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
|
||||
|
||||
// Join game
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
// New Game Modal
|
||||
const [newGameOpen, setNewGameOpen] = useState(false);
|
||||
|
||||
const currentGame = useMemo(
|
||||
() => games.find((g) => String(g.id) === String(gameId)) || null,
|
||||
[games, gameId]
|
||||
);
|
||||
|
||||
// ===== Data loaders =====
|
||||
const load = async () => {
|
||||
const m = await api("/auth/me");
|
||||
setMe(m);
|
||||
@@ -89,21 +82,16 @@ export default function App() {
|
||||
setSheet(sh);
|
||||
};
|
||||
|
||||
const reloadMeta = async () => {
|
||||
const loadGameMeta = async () => {
|
||||
if (!gameId) return;
|
||||
const meta = await api(`/games/${gameId}/meta`);
|
||||
const meta = await api(`/games/${gameId}`);
|
||||
setGameMeta(meta);
|
||||
setWinnerUserId(meta?.winner?.id || null);
|
||||
};
|
||||
setWinnerUserId(meta?.winner_user_id || "");
|
||||
|
||||
const reloadPlayers = async () => {
|
||||
if (!gameId) return;
|
||||
const ps = await api(`/games/${gameId}/players`);
|
||||
setPlayers(ps);
|
||||
const mem = await api(`/games/${gameId}/members`);
|
||||
setMembers(mem);
|
||||
};
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Dropdown outside click
|
||||
useEffect(() => {
|
||||
const onDown = (e) => {
|
||||
@@ -114,24 +102,23 @@ export default function App() {
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [userMenuOpen]);
|
||||
|
||||
// initial load (try session)
|
||||
// initial load
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
// not logged in
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// load sheet/meta when game changes
|
||||
// on game change
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!gameId) return;
|
||||
try {
|
||||
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
|
||||
await reloadSheet();
|
||||
await loadGameMeta();
|
||||
} catch {}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -153,11 +140,11 @@ export default function App() {
|
||||
setGameId(null);
|
||||
setSheet(null);
|
||||
setGameMeta(null);
|
||||
setPlayers([]);
|
||||
setWinnerUserId(null);
|
||||
setMembers([]);
|
||||
setWinnerUserId("");
|
||||
};
|
||||
|
||||
// ===== Password change =====
|
||||
// ===== Password =====
|
||||
const openPwModal = () => {
|
||||
setPwMsg("");
|
||||
setPw1("");
|
||||
@@ -194,7 +181,7 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Theme actions =====
|
||||
// ===== Theme =====
|
||||
const openDesignModal = () => {
|
||||
setDesignOpen(true);
|
||||
setUserMenuOpen(false);
|
||||
@@ -209,26 +196,24 @@ export default function App() {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ theme_key: key }),
|
||||
});
|
||||
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
|
||||
} catch {
|
||||
// ignore; UI already switched
|
||||
// theme locally already applied; ignore backend error
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Game actions =====
|
||||
const newGame = async () => {
|
||||
// ===== New game flow =====
|
||||
const createGame = async () => {
|
||||
const g = await api("/games", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
||||
});
|
||||
|
||||
const gs = await api("/games");
|
||||
setGames(gs);
|
||||
setGameId(g.id);
|
||||
};
|
||||
|
||||
const openJoinModal = () => {
|
||||
setJoinOpen(true);
|
||||
setUserMenuOpen(false);
|
||||
// meta/members will load via gameId effect
|
||||
return g; // includes code
|
||||
};
|
||||
|
||||
const joinGame = async (code) => {
|
||||
@@ -236,19 +221,20 @@ export default function App() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
const gs = await api("/games");
|
||||
setGames(gs);
|
||||
setGameId(res?.game?.id || null);
|
||||
setGameId(res.id);
|
||||
};
|
||||
|
||||
// ===== Winner actions (shared per game) =====
|
||||
// ===== Winner =====
|
||||
const saveWinner = async () => {
|
||||
if (!gameId) return;
|
||||
await api(`/games/${gameId}/winner`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
||||
});
|
||||
await reloadMeta();
|
||||
await loadGameMeta();
|
||||
};
|
||||
|
||||
// ===== Sheet actions =====
|
||||
@@ -278,9 +264,11 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === null) clearChipLS(gameId, entry.entry_id);
|
||||
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: next }),
|
||||
body: JSON.stringify({ note_tag: next, chip: null }),
|
||||
});
|
||||
|
||||
await reloadSheet();
|
||||
@@ -293,10 +281,12 @@ export default function App() {
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
|
||||
setChipLS(gameId, entry.entry_id, chip);
|
||||
|
||||
try {
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: "s", chip_code: chip }),
|
||||
body: JSON.stringify({ note_tag: "s", chip }),
|
||||
});
|
||||
} finally {
|
||||
await reloadSheet();
|
||||
@@ -313,10 +303,12 @@ export default function App() {
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
|
||||
clearChipLS(gameId, entry.entry_id);
|
||||
|
||||
try {
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: null }),
|
||||
body: JSON.stringify({ note_tag: null, chip: null }),
|
||||
});
|
||||
} finally {
|
||||
await reloadSheet();
|
||||
@@ -326,10 +318,14 @@ export default function App() {
|
||||
const displayTag = (entry) => {
|
||||
const t = entry.note_tag;
|
||||
if (!t) return "—";
|
||||
|
||||
if (t === "s") {
|
||||
return entry.chip_code ? `s.${entry.chip_code}` : "s";
|
||||
// Prefer backend chip, fallback localStorage
|
||||
const chip = entry.chip || getChipLS(gameId, entry.entry_id);
|
||||
return chip ? `s.${chip}` : "s";
|
||||
}
|
||||
return t;
|
||||
|
||||
return t; // i oder m
|
||||
};
|
||||
|
||||
// ===== Login page =====
|
||||
@@ -355,7 +351,7 @@ export default function App() {
|
||||
]
|
||||
: [];
|
||||
|
||||
const winnerObj = gameMeta?.winner || null;
|
||||
const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id);
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
@@ -370,9 +366,8 @@ export default function App() {
|
||||
setUserMenuOpen={setUserMenuOpen}
|
||||
openPwModal={openPwModal}
|
||||
openDesignModal={openDesignModal}
|
||||
openJoinModal={openJoinModal}
|
||||
doLogout={doLogout}
|
||||
newGame={newGame}
|
||||
onOpenNewGame={() => setNewGameOpen(true)}
|
||||
/>
|
||||
|
||||
{me.role === "admin" && <AdminPanel />}
|
||||
@@ -381,11 +376,11 @@ export default function App() {
|
||||
games={games}
|
||||
gameId={gameId}
|
||||
setGameId={setGameId}
|
||||
joinCode={currentGame?.join_code || ""}
|
||||
onOpenHelp={() => setHelpOpen(true)}
|
||||
/>
|
||||
|
||||
{winnerObj && <WinnerBadge winnerEmail={winnerObj.email} />}
|
||||
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
|
||||
<WinnerBadge winnerEmail={gameMeta?.winner_email || ""} />
|
||||
|
||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
|
||||
@@ -403,9 +398,10 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sieger (shared per Spiel) */}
|
||||
{/* Host-only Winner Auswahl */}
|
||||
<WinnerCard
|
||||
players={players}
|
||||
isHost={isHost}
|
||||
members={members}
|
||||
winnerUserId={winnerUserId}
|
||||
setWinnerUserId={setWinnerUserId}
|
||||
onSave={saveWinner}
|
||||
@@ -430,19 +426,17 @@ export default function App() {
|
||||
open={designOpen}
|
||||
onClose={() => setDesignOpen(false)}
|
||||
themeKey={themeKey}
|
||||
onSelect={async (k) => {
|
||||
await selectTheme(k);
|
||||
onSelect={(k) => {
|
||||
selectTheme(k);
|
||||
setDesignOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<JoinGameModal
|
||||
open={joinOpen}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
onJoin={async (code) => {
|
||||
await joinGame(code);
|
||||
setJoinOpen(false);
|
||||
}}
|
||||
<NewGameModal
|
||||
open={newGameOpen}
|
||||
onClose={() => setNewGameOpen(false)}
|
||||
onCreate={createGame}
|
||||
onJoin={joinGame}
|
||||
/>
|
||||
|
||||
<ChipModal
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/GamePickerCard.jsx
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function GamePickerCard({ games, gameId, setGameId, joinCode, onOpenHelp }) {
|
||||
export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) {
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={styles.card}>
|
||||
@@ -17,7 +16,7 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
|
||||
>
|
||||
{games.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
{g.name} {g.code ? `• ${g.code}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -27,18 +26,16 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
{/* kleine Code Zeile unter dem Picker (optional nice) */}
|
||||
{(() => {
|
||||
const cur = games.find((x) => x.id === gameId);
|
||||
if (!cur?.code) return null;
|
||||
return (
|
||||
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
180
frontend/src/components/NewGameModal.jsx
Normal file
180
frontend/src/components/NewGameModal.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function NewGameModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreate,
|
||||
onJoin,
|
||||
}) {
|
||||
const [mode, setMode] = useState("choice"); // choice | create | join
|
||||
const [joinCode, setJoinCode] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [created, setCreated] = useState(null); // { code }
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(""), 1100);
|
||||
};
|
||||
|
||||
const doCreate = async () => {
|
||||
setErr("");
|
||||
try {
|
||||
const res = await onCreate();
|
||||
setCreated({ code: res.code });
|
||||
setMode("create");
|
||||
} catch (e) {
|
||||
setErr("❌ Fehler: " + (e?.message || "unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
const doJoin = async () => {
|
||||
setErr("");
|
||||
try {
|
||||
await onJoin(joinCode.trim().toUpperCase());
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr("❌ Fehler: " + (e?.message || "unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(created?.code || "");
|
||||
showToast("✅ Code kopiert");
|
||||
} catch {
|
||||
showToast("❌ Copy nicht möglich");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.modalOverlay} onMouseDown={onClose}>
|
||||
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div style={styles.modalHeader}>
|
||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
|
||||
Spiel
|
||||
</div>
|
||||
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||
background: stylesTokens.panelBg,
|
||||
color: stylesTokens.textMain,
|
||||
fontWeight: 900,
|
||||
textAlign: "center",
|
||||
animation: "fadeIn 120ms ease-out",
|
||||
}}
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||
{mode === "choice" && (
|
||||
<>
|
||||
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
|
||||
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?
|
||||
</div>
|
||||
|
||||
<button onClick={doCreate} style={styles.primaryBtn}>
|
||||
✦ Spiel erstellen
|
||||
</button>
|
||||
|
||||
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
|
||||
⎆ Spiel beitreten
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "join" && (
|
||||
<>
|
||||
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
|
||||
Gib den <b>Code</b> ein:
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={joinCode}
|
||||
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
||||
placeholder="z.B. 8K3MZQ"
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
|
||||
Zurück
|
||||
</button>
|
||||
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
|
||||
Beitreten
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "create" && created && (
|
||||
<>
|
||||
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
|
||||
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei „Alte Spiele“ sichtbar:
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||
background: stylesTokens.panelBg,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||
Spiel-Code
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 1100,
|
||||
letterSpacing: 2,
|
||||
color: stylesTokens.textGold,
|
||||
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
|
||||
}}
|
||||
>
|
||||
{created.code}
|
||||
</div>
|
||||
|
||||
<button onClick={copyCode} style={styles.primaryBtn}>
|
||||
⧉ Code kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={onClose} style={styles.primaryBtn}>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{err && <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,22 +8,27 @@ export default function TopBar({
|
||||
setUserMenuOpen,
|
||||
openPwModal,
|
||||
openDesignModal,
|
||||
openJoinModal,
|
||||
doLogout,
|
||||
newGame,
|
||||
onOpenNewGame, // NEW
|
||||
}) {
|
||||
return (
|
||||
<div style={styles.topBar}>
|
||||
{/* LINKS */}
|
||||
<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={{ fontWeight: 900, color: stylesTokens.textGold }}>
|
||||
Notizbogen
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||
{me.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RECHTS */}
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü">
|
||||
<button
|
||||
onClick={() => setUserMenuOpen((v) => !v)}
|
||||
style={styles.userBtn}
|
||||
title="User Menü"
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>👤</span>
|
||||
<span>User</span>
|
||||
<span style={{ opacity: 0.7 }}>▾</span>
|
||||
@@ -31,14 +36,13 @@ export default function TopBar({
|
||||
|
||||
{userMenuOpen && (
|
||||
<div style={styles.userDropdown}>
|
||||
{/* Email Info */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
opacity: 0.85,
|
||||
color: stylesTokens.textDim,
|
||||
borderBottom: `1px solid ${stylesTokens.goldLine}`,
|
||||
borderBottom: "1px solid rgba(233,216,166,0.12)",
|
||||
}}
|
||||
>
|
||||
{me.email}
|
||||
@@ -48,26 +52,10 @@ export default function TopBar({
|
||||
Passwort setzen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openDesignModal();
|
||||
}}
|
||||
style={styles.userDropdownItem}
|
||||
>
|
||||
<button onClick={openDesignModal} style={styles.userDropdownItem}>
|
||||
Design ändern
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openJoinModal();
|
||||
}}
|
||||
style={styles.userDropdownItem}
|
||||
>
|
||||
Spiel beitreten
|
||||
</button>
|
||||
|
||||
<div style={styles.userDropdownDivider} />
|
||||
|
||||
<button
|
||||
@@ -83,8 +71,8 @@ export default function TopBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={newGame} style={styles.primaryBtn}>
|
||||
✦ Neues Spiel
|
||||
<button onClick={onOpenNewGame} style={styles.primaryBtn}>
|
||||
✦ New Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,20 +8,29 @@ export default function WinnerBadge({ winnerEmail }) {
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: "12px 14px",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||
background: stylesTokens.panelBg,
|
||||
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
|
||||
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
|
||||
backdropFilter: "blur(6px)",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>🏆</span>
|
||||
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
|
||||
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ fontSize: 18 }}>🏆</div>
|
||||
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
|
||||
Sieger:
|
||||
<span style={{ color: stylesTokens.textGold }}>{" "}{winnerEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
||||
festgelegt
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,42 @@
|
||||
// src/components/WinnerCard.jsx
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
export default function WinnerCard({
|
||||
isHost,
|
||||
members,
|
||||
winnerUserId,
|
||||
setWinnerUserId,
|
||||
onSave,
|
||||
}) {
|
||||
if (!isHost) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.sectionHeader}>Sieger</div>
|
||||
|
||||
<div style={{ padding: 12, display: "grid", gap: 10 }}>
|
||||
{!hasPlayers ? (
|
||||
<div style={{ color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||
Keine Spieler gefunden (Admin wird nicht angezeigt).
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
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>
|
||||
)}
|
||||
<div style={styles.cardBody}>
|
||||
<select
|
||||
value={winnerUserId || ""}
|
||||
onChange={(e) => setWinnerUserId(e.target.value || "")}
|
||||
style={{ ...styles.input, flex: 1 }}
|
||||
>
|
||||
<option value="">— kein Sieger —</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onSave} style={styles.primaryBtn}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||
Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar.
|
||||
</div>
|
||||
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||
Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user