Refactor and enhance game management, user roles, and state handling
This commit introduces significant changes across the backend and frontend to improve game creation, joining, and member management. Key updates include adding a host role, structured handling of winners, and a New Game modal in the frontend. The refactor also simplifies join codes, improves persistence for user themes, and enhances overall user interaction with better UI feedback and logic.
This commit is contained in:
@@ -1,19 +1,30 @@
|
|||||||
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
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from .db import Base
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
class Role(str, enum.Enum):
|
class Role(str, enum.Enum):
|
||||||
admin = "admin"
|
admin = "admin"
|
||||||
user = "user"
|
user = "user"
|
||||||
|
|
||||||
|
|
||||||
class Category(str, enum.Enum):
|
class Category(str, enum.Enum):
|
||||||
suspect = "suspect"
|
suspect = "suspect"
|
||||||
item = "item"
|
item = "item"
|
||||||
location = "location"
|
location = "location"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
@@ -21,40 +32,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)
|
||||||
|
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")
|
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):
|
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
|
# NEW: Host (nur Host darf Winner setzen)
|
||||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String)
|
name: Mapped[str] = mapped_column(String)
|
||||||
seed: Mapped[int] = mapped_column(Integer)
|
seed: Mapped[int] = mapped_column(Integer)
|
||||||
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# "Kahoot"-style join code
|
# NEW: Join-Code (Kahoot-Style)
|
||||||
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
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)
|
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):
|
class GameMember(Base):
|
||||||
__tablename__ = "game_members"
|
__tablename__ = "game_members"
|
||||||
__table_args__ = (
|
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_member"),)
|
||||||
UniqueConstraint("game_id", "user_id", name="uq_game_member"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||||
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.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):
|
class Entry(Base):
|
||||||
__tablename__ = "entries"
|
__tablename__ = "entries"
|
||||||
@@ -62,11 +72,13 @@ class Entry(Base):
|
|||||||
category: Mapped[str] = mapped_column(String, index=True)
|
category: Mapped[str] = mapped_column(String, index=True)
|
||||||
label: Mapped[str] = mapped_column(String)
|
label: Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
|
||||||
class SheetState(Base):
|
class SheetState(Base):
|
||||||
__tablename__ = "sheet_state"
|
__tablename__ = "sheet_state"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
|
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||||
@@ -75,6 +87,6 @@ class SheetState(Base):
|
|||||||
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe
|
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)
|
# NEW: Chip persistieren (statt LocalStorage)
|
||||||
chip_code: Mapped[str | None] = mapped_column(String, nullable=True)
|
chip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
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 (
|
from ..security import (
|
||||||
@@ -14,21 +13,25 @@ from ..security import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
|
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
|
||||||
email = (data.get("email") or "").lower().strip()
|
email = (data.get("email") or "").lower().strip()
|
||||||
password = data.get("password") or ""
|
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):
|
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, "theme_key": user.theme_key}
|
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(resp: Response):
|
def logout(resp: Response):
|
||||||
clear_session(resp)
|
clear_session(resp)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
def me(req: Request, db: Session = Depends(get_db)):
|
def me(req: Request, db: Session = Depends(get_db)):
|
||||||
uid = get_session_user_id(req)
|
uid = get_session_user_id(req)
|
||||||
@@ -39,6 +42,7 @@ def me(req: Request, db: Session = Depends(get_db)):
|
|||||||
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, "theme_key": user.theme_key}
|
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)
|
||||||
@@ -49,19 +53,18 @@ 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() # noqa: E712
|
user = db.query(User).filter(User.id == uid, User.disabled == False).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")
|
||||||
|
|
||||||
user.password_hash = hash_password(password)
|
user.password_hash = hash_password(password)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/theme")
|
@router.patch("/theme")
|
||||||
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
|
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||||
"""Persist user design selection server-side."""
|
|
||||||
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")
|
||||||
@@ -70,7 +73,7 @@ def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
|
|||||||
if not theme_key:
|
if not theme_key:
|
||||||
raise HTTPException(status_code=400, detail="theme_key required")
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="not logged in")
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +1,193 @@
|
|||||||
import hashlib
|
import hashlib, random
|
||||||
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, GameMember, Entry, SheetState, Category, User, Role
|
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
|
||||||
from ..security import get_session_user_id
|
from ..security import get_session_user_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/games", tags=["games"])
|
router = APIRouter(prefix="/games", tags=["games"])
|
||||||
|
|
||||||
def require_user(req: Request, db: Session) -> str:
|
|
||||||
|
def require_user(req: Request, db: Session):
|
||||||
uid = get_session_user_id(req)
|
uid = get_session_user_id(req)
|
||||||
if not uid:
|
if not uid:
|
||||||
raise HTTPException(status_code=401, detail="not logged in")
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
||||||
s = f"{seed}:{user_id}:{entry_id}".encode()
|
s = f"{seed}:{user_id}:{entry_id}".encode()
|
||||||
return hashlib.sha256(s).hexdigest()
|
return hashlib.sha256(s).hexdigest()
|
||||||
|
|
||||||
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:
|
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||||
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)
|
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()
|
g = db.query(Game).filter(Game.id == game_id).first()
|
||||||
if not g:
|
if not g:
|
||||||
raise HTTPException(404, "game not found")
|
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")
|
raise HTTPException(403, "not a member of this game")
|
||||||
return uid, g
|
return g
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
uid = require_user(req, db)
|
uid = require_user(req, db)
|
||||||
|
|
||||||
name = data.get("name") or "Neues Spiel"
|
name = data.get("name") or "Neues Spiel"
|
||||||
seed = random.randint(1, 2_000_000_000)
|
seed = random.randint(1, 2_000_000_000)
|
||||||
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.add(g)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# creator becomes member
|
# creator joins automatically
|
||||||
db.add(GameMember(game_id=g.id, user_id=uid))
|
ensure_member(db, g.id, uid)
|
||||||
db.commit()
|
|
||||||
|
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")
|
@router.post("/join")
|
||||||
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
uid = require_user(req, db)
|
uid = require_user(req, db)
|
||||||
code = (data.get("code") or "").strip()
|
code = (data.get("code") or "").strip().upper()
|
||||||
if not code or len(code) < 4:
|
if not code:
|
||||||
raise HTTPException(400, "code required")
|
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:
|
if not g:
|
||||||
raise HTTPException(404, "game not found")
|
raise HTTPException(404, "game not found")
|
||||||
|
|
||||||
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
ensure_member(db, g.id, uid)
|
||||||
if not exists:
|
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
|
||||||
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 = (
|
# list games where user is member
|
||||||
|
q = (
|
||||||
db.query(Game)
|
db.query(Game)
|
||||||
.join(GameMember, GameMember.game_id == Game.id)
|
.join(GameMember, GameMember.game_id == Game.id)
|
||||||
.filter(GameMember.user_id == uid)
|
.filter(GameMember.user_id == uid)
|
||||||
.order_by(Game.created_at.desc())
|
.order_by(Game.created_at.desc())
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
games = q.all()
|
||||||
|
|
||||||
|
# winner email (optional)
|
||||||
out = []
|
out = []
|
||||||
for g in games:
|
for g in games:
|
||||||
winner = None
|
winner_email = None
|
||||||
if g.winner_user_id:
|
if g.winner_user_id:
|
||||||
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||||
if wu:
|
winner_email = wu.email if wu else None
|
||||||
winner = {"id": wu.id, "email": wu.email}
|
out.append(
|
||||||
out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner})
|
{
|
||||||
|
"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
|
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:
|
if g.winner_user_id:
|
||||||
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||||
if wu:
|
winner_email = wu.email if wu else None
|
||||||
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")
|
return {
|
||||||
def list_players(req: Request, game_id: str, db: Session = Depends(get_db)):
|
"id": g.id,
|
||||||
_uid, g = require_member(req, db, game_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)
|
db.query(User)
|
||||||
.join(GameMember, GameMember.user_id == User.id)
|
.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())
|
.order_by(User.email.asc())
|
||||||
.all()
|
.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")
|
@router.patch("/{game_id}/winner")
|
||||||
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
|
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")
|
winner_user_id = data.get("winner_user_id")
|
||||||
if winner_user_id is not None:
|
if winner_user_id is None:
|
||||||
# must be a member + non-admin
|
g.winner_user_id = None
|
||||||
u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712
|
db.add(g)
|
||||||
if not u or u.role != Role.user.value:
|
db.commit()
|
||||||
raise HTTPException(400, "invalid winner_user_id")
|
return {"ok": True, "winner_user_id": None}
|
||||||
|
|
||||||
member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first()
|
# must be a member AND role=user
|
||||||
if not member:
|
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
|
||||||
raise HTTPException(400, "winner is not in this game")
|
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
|
g.winner_user_id = winner_user_id
|
||||||
db.add(g)
|
db.add(g)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"ok": True, "winner_user_id": g.winner_user_id}
|
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, 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()
|
entries = db.query(Entry).all()
|
||||||
states = (
|
states = (
|
||||||
@@ -163,12 +205,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|||||||
"label": e.label,
|
"label": e.label,
|
||||||
"status": st.status if st else 0,
|
"status": st.status if st else 0,
|
||||||
"note_tag": st.note_tag if st else None,
|
"note_tag": st.note_tag if st else None,
|
||||||
"chip_code": st.chip_code if st else None,
|
"chip": st.chip if st else None, # NEW
|
||||||
"order": stable_order(g.seed, uid, e.id),
|
"order": stable_order(g.seed, uid, e.id),
|
||||||
}
|
}
|
||||||
out[e.category].append(item)
|
out[e.category].append(item)
|
||||||
|
|
||||||
# sort within category
|
|
||||||
for k in out:
|
for k in out:
|
||||||
out[k].sort(key=lambda x: x["order"])
|
out[k].sort(key=lambda x: x["order"])
|
||||||
for i in out[k]:
|
for i in out[k]:
|
||||||
@@ -176,49 +217,58 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{game_id}/sheet/{entry_id}")
|
@router.patch("/{game_id}/sheet/{entry_id}")
|
||||||
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
||||||
uid, g = require_member(req, db, game_id)
|
uid = require_user(req, db)
|
||||||
|
g = require_game_member(db, game_id, uid)
|
||||||
|
|
||||||
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")
|
chip = data.get("chip")
|
||||||
|
|
||||||
if note_tag not in (None, "i", "m", "s"):
|
if note_tag not in (None, "i", "m", "s"):
|
||||||
raise HTTPException(400, "invalid note_tag")
|
raise HTTPException(400, "invalid note_tag")
|
||||||
if status is not None and status not in (0, 1, 2, 3):
|
if status is not None and status not in (0, 1, 2, 3):
|
||||||
raise HTTPException(400, "invalid status")
|
raise HTTPException(400, "invalid status")
|
||||||
|
|
||||||
# chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's')
|
if chip is not None:
|
||||||
if chip_code is not None:
|
chip = (chip or "").strip().upper()
|
||||||
if not isinstance(chip_code, str) or len(chip_code) > 16:
|
if chip == "":
|
||||||
raise HTTPException(400, "invalid chip_code")
|
chip = None
|
||||||
|
if chip is not None:
|
||||||
|
if len(chip) > 8:
|
||||||
|
raise HTTPException(400, "invalid chip")
|
||||||
|
|
||||||
st = db.query(SheetState).filter(
|
st = (
|
||||||
SheetState.game_id == g.id,
|
db.query(SheetState)
|
||||||
SheetState.owner_user_id == uid,
|
.filter(
|
||||||
SheetState.entry_id == entry_id
|
SheetState.game_id == g.id,
|
||||||
).first()
|
SheetState.owner_user_id == uid,
|
||||||
|
SheetState.entry_id == entry_id,
|
||||||
|
)
|
||||||
|
.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, 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)
|
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:
|
# wenn note_tag zurück auf null -> chip auch löschen
|
||||||
# chip_code is only meaningful when note_tag is 's'
|
if note_tag is None:
|
||||||
effective_tag = st.note_tag
|
st.chip = None
|
||||||
if effective_tag != "s":
|
|
||||||
raise HTTPException(400, "chip_code requires note_tag 's'")
|
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
|
||||||
st.chip_code = chip_code
|
if "chip" in data:
|
||||||
|
if st.note_tag == "s":
|
||||||
|
st.chip = chip
|
||||||
|
else:
|
||||||
|
st.chip = None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
// 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 { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
|
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
|
||||||
import { styles } from "./styles/styles";
|
import { styles } from "./styles/styles";
|
||||||
|
|
||||||
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
|
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
|
||||||
|
|
||||||
import AdminPanel from "./components/AdminPanel";
|
import AdminPanel from "./components/AdminPanel";
|
||||||
@@ -20,7 +19,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";
|
import NewGameModal from "./components/NewGameModal";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useHpGlobalStyles();
|
useHpGlobalStyles();
|
||||||
@@ -37,17 +36,17 @@ export default function App() {
|
|||||||
const [sheet, setSheet] = useState(null);
|
const [sheet, setSheet] = useState(null);
|
||||||
const [pulseId, setPulseId] = useState(null);
|
const [pulseId, setPulseId] = useState(null);
|
||||||
|
|
||||||
// Game meta / players / winner
|
// Game meta
|
||||||
const [gameMeta, setGameMeta] = useState(null);
|
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
|
||||||
const [players, setPlayers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [winnerUserId, setWinnerUserId] = useState(null);
|
|
||||||
|
// Winner selection (host only)
|
||||||
|
const [winnerUserId, setWinnerUserId] = useState("");
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
const [chipOpen, setChipOpen] = useState(false);
|
const [chipOpen, setChipOpen] = useState(false);
|
||||||
const [chipEntry, setChipEntry] = useState(null);
|
const [chipEntry, setChipEntry] = useState(null);
|
||||||
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
const [pwOpen, setPwOpen] = useState(false);
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
@@ -60,15 +59,9 @@ 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
|
// New Game Modal
|
||||||
const [joinOpen, setJoinOpen] = useState(false);
|
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 load = async () => {
|
||||||
const m = await api("/auth/me");
|
const m = await api("/auth/me");
|
||||||
setMe(m);
|
setMe(m);
|
||||||
@@ -89,21 +82,16 @@ export default function App() {
|
|||||||
setSheet(sh);
|
setSheet(sh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadMeta = async () => {
|
const loadGameMeta = async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
const meta = await api(`/games/${gameId}/meta`);
|
const meta = await api(`/games/${gameId}`);
|
||||||
setGameMeta(meta);
|
setGameMeta(meta);
|
||||||
setWinnerUserId(meta?.winner?.id || null);
|
setWinnerUserId(meta?.winner_user_id || "");
|
||||||
};
|
|
||||||
|
|
||||||
const reloadPlayers = async () => {
|
const mem = await api(`/games/${gameId}/members`);
|
||||||
if (!gameId) return;
|
setMembers(mem);
|
||||||
const ps = await api(`/games/${gameId}/players`);
|
|
||||||
setPlayers(ps);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Effects =====
|
|
||||||
|
|
||||||
// Dropdown outside click
|
// Dropdown outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = (e) => {
|
const onDown = (e) => {
|
||||||
@@ -114,24 +102,23 @@ export default function App() {
|
|||||||
return () => document.removeEventListener("mousedown", onDown);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
}, [userMenuOpen]);
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
// initial load (try session)
|
// initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {}
|
||||||
// not logged in
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// load sheet/meta when game changes
|
// on game change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
try {
|
try {
|
||||||
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
|
await reloadSheet();
|
||||||
|
await loadGameMeta();
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -153,11 +140,11 @@ export default function App() {
|
|||||||
setGameId(null);
|
setGameId(null);
|
||||||
setSheet(null);
|
setSheet(null);
|
||||||
setGameMeta(null);
|
setGameMeta(null);
|
||||||
setPlayers([]);
|
setMembers([]);
|
||||||
setWinnerUserId(null);
|
setWinnerUserId("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Password change =====
|
// ===== Password =====
|
||||||
const openPwModal = () => {
|
const openPwModal = () => {
|
||||||
setPwMsg("");
|
setPwMsg("");
|
||||||
setPw1("");
|
setPw1("");
|
||||||
@@ -194,7 +181,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Theme actions =====
|
// ===== Theme =====
|
||||||
const openDesignModal = () => {
|
const openDesignModal = () => {
|
||||||
setDesignOpen(true);
|
setDesignOpen(true);
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
@@ -209,26 +196,24 @@ export default function App() {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ theme_key: key }),
|
body: JSON.stringify({ theme_key: key }),
|
||||||
});
|
});
|
||||||
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; UI already switched
|
// theme locally already applied; ignore backend error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Game actions =====
|
// ===== New game flow =====
|
||||||
const newGame = async () => {
|
const createGame = async () => {
|
||||||
const g = await api("/games", {
|
const g = await api("/games", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const gs = await api("/games");
|
const gs = await api("/games");
|
||||||
setGames(gs);
|
setGames(gs);
|
||||||
setGameId(g.id);
|
setGameId(g.id);
|
||||||
};
|
|
||||||
|
|
||||||
const openJoinModal = () => {
|
// meta/members will load via gameId effect
|
||||||
setJoinOpen(true);
|
return g; // includes code
|
||||||
setUserMenuOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const joinGame = async (code) => {
|
const joinGame = async (code) => {
|
||||||
@@ -236,19 +221,20 @@ export default function App() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ code }),
|
body: JSON.stringify({ code }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const gs = await api("/games");
|
const gs = await api("/games");
|
||||||
setGames(gs);
|
setGames(gs);
|
||||||
setGameId(res?.game?.id || null);
|
setGameId(res.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Winner actions (shared per game) =====
|
// ===== Winner =====
|
||||||
const saveWinner = async () => {
|
const saveWinner = async () => {
|
||||||
if (!gameId) return;
|
if (!gameId) return;
|
||||||
await api(`/games/${gameId}/winner`, {
|
await api(`/games/${gameId}/winner`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
||||||
});
|
});
|
||||||
await reloadMeta();
|
await loadGameMeta();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== Sheet actions =====
|
// ===== Sheet actions =====
|
||||||
@@ -278,9 +264,11 @@ 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, chip: null }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
@@ -293,10 +281,12 @@ 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", chip_code: chip }),
|
body: JSON.stringify({ note_tag: "s", chip }),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
@@ -313,10 +303,12 @@ 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",
|
||||||
body: JSON.stringify({ note_tag: null }),
|
body: JSON.stringify({ note_tag: null, chip: null }),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await reloadSheet();
|
await reloadSheet();
|
||||||
@@ -326,10 +318,14 @@ export default function App() {
|
|||||||
const displayTag = (entry) => {
|
const displayTag = (entry) => {
|
||||||
const t = entry.note_tag;
|
const t = entry.note_tag;
|
||||||
if (!t) return "—";
|
if (!t) return "—";
|
||||||
|
|
||||||
if (t === "s") {
|
if (t === "s") {
|
||||||
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 =====
|
// ===== 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 (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
@@ -370,9 +366,8 @@ export default function App() {
|
|||||||
setUserMenuOpen={setUserMenuOpen}
|
setUserMenuOpen={setUserMenuOpen}
|
||||||
openPwModal={openPwModal}
|
openPwModal={openPwModal}
|
||||||
openDesignModal={openDesignModal}
|
openDesignModal={openDesignModal}
|
||||||
openJoinModal={openJoinModal}
|
|
||||||
doLogout={doLogout}
|
doLogout={doLogout}
|
||||||
newGame={newGame}
|
onOpenNewGame={() => setNewGameOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{me.role === "admin" && <AdminPanel />}
|
{me.role === "admin" && <AdminPanel />}
|
||||||
@@ -381,11 +376,11 @@ 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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{winnerObj && <WinnerBadge winnerEmail={winnerObj.email} />}
|
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
|
||||||
|
<WinnerBadge winnerEmail={gameMeta?.winner_email || ""} />
|
||||||
|
|
||||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
|
|
||||||
@@ -403,9 +398,10 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sieger (shared per Spiel) */}
|
{/* Host-only Winner Auswahl */}
|
||||||
<WinnerCard
|
<WinnerCard
|
||||||
players={players}
|
isHost={isHost}
|
||||||
|
members={members}
|
||||||
winnerUserId={winnerUserId}
|
winnerUserId={winnerUserId}
|
||||||
setWinnerUserId={setWinnerUserId}
|
setWinnerUserId={setWinnerUserId}
|
||||||
onSave={saveWinner}
|
onSave={saveWinner}
|
||||||
@@ -430,19 +426,17 @@ export default function App() {
|
|||||||
open={designOpen}
|
open={designOpen}
|
||||||
onClose={() => setDesignOpen(false)}
|
onClose={() => setDesignOpen(false)}
|
||||||
themeKey={themeKey}
|
themeKey={themeKey}
|
||||||
onSelect={async (k) => {
|
onSelect={(k) => {
|
||||||
await selectTheme(k);
|
selectTheme(k);
|
||||||
setDesignOpen(false);
|
setDesignOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JoinGameModal
|
<NewGameModal
|
||||||
open={joinOpen}
|
open={newGameOpen}
|
||||||
onClose={() => setJoinOpen(false)}
|
onClose={() => setNewGameOpen(false)}
|
||||||
onJoin={async (code) => {
|
onCreate={createGame}
|
||||||
await joinGame(code);
|
onJoin={joinGame}
|
||||||
setJoinOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChipModal
|
<ChipModal
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
// src/components/GamePickerCard.jsx
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { styles } from "../styles/styles";
|
import { styles } from "../styles/styles";
|
||||||
import { stylesTokens } from "../styles/theme";
|
import { stylesTokens } from "../styles/theme";
|
||||||
|
|
||||||
export default function GamePickerCard({ games, gameId, setGameId, joinCode, onOpenHelp }) {
|
export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
@@ -17,7 +16,7 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
|
|||||||
>
|
>
|
||||||
{games.map((g) => (
|
{games.map((g) => (
|
||||||
<option key={g.id} value={g.id}>
|
<option key={g.id} value={g.id}>
|
||||||
{g.name}
|
{g.name} {g.code ? `• ${g.code}` : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -27,18 +26,16 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!joinCode && (
|
{/* kleine Code Zeile unter dem Picker (optional nice) */}
|
||||||
<div
|
{(() => {
|
||||||
style={{
|
const cur = games.find((x) => x.id === gameId);
|
||||||
padding: "0 12px 12px",
|
if (!cur?.code) return null;
|
||||||
fontSize: 12,
|
return (
|
||||||
opacity: 0.85,
|
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||||
color: stylesTokens.textDim,
|
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
|
||||||
}}
|
</div>
|
||||||
>
|
);
|
||||||
Spiel-Code: <b style={{ color: stylesTokens.textGold }}>{joinCode}</b>
|
})()}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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,
|
setUserMenuOpen,
|
||||||
openPwModal,
|
openPwModal,
|
||||||
openDesignModal,
|
openDesignModal,
|
||||||
openJoinModal,
|
|
||||||
doLogout,
|
doLogout,
|
||||||
newGame,
|
onOpenNewGame, // NEW
|
||||||
}) {
|
}) {
|
||||||
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 }}>
|
||||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div>
|
Notizbogen
|
||||||
|
</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ü"
|
||||||
|
>
|
||||||
<span style={{ fontSize: 16 }}>👤</span>
|
<span style={{ fontSize: 16 }}>👤</span>
|
||||||
<span>User</span>
|
<span>User</span>
|
||||||
<span style={{ opacity: 0.7 }}>▾</span>
|
<span style={{ opacity: 0.7 }}>▾</span>
|
||||||
@@ -31,14 +36,13 @@ 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 ${stylesTokens.goldLine}`,
|
borderBottom: "1px solid rgba(233,216,166,0.12)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{me.email}
|
{me.email}
|
||||||
@@ -48,26 +52,10 @@ export default function TopBar({
|
|||||||
Passwort setzen
|
Passwort setzen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button onClick={openDesignModal} style={styles.userDropdownItem}>
|
||||||
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
|
||||||
@@ -83,8 +71,8 @@ export default function TopBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={newGame} style={styles.primaryBtn}>
|
<button onClick={onOpenNewGame} style={styles.primaryBtn}>
|
||||||
✦ Neues Spiel
|
✦ New Game
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,20 +8,29 @@ export default function WinnerBadge({ winnerEmail }) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 14,
|
marginTop: 14,
|
||||||
padding: "12px 14px",
|
padding: "10px 12px",
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||||
background: stylesTokens.panelBg,
|
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)",
|
backdropFilter: "blur(6px)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 10,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 16 }}>🏆</span>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
|
<div style={{ fontSize: 18 }}>🏆</div>
|
||||||
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,42 @@
|
|||||||
// 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({
|
||||||
* props:
|
isHost,
|
||||||
* - players: [{id,email}]
|
members,
|
||||||
* - winnerUserId: string|null
|
winnerUserId,
|
||||||
* - setWinnerUserId: fn
|
setWinnerUserId,
|
||||||
* - onSave: fn (async ok)
|
onSave,
|
||||||
*/
|
}) {
|
||||||
export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) {
|
if (!isHost) return null;
|
||||||
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={{ padding: 12, display: "grid", gap: 10 }}>
|
<div style={styles.cardBody}>
|
||||||
{!hasPlayers ? (
|
<select
|
||||||
<div style={{ color: stylesTokens.textDim, opacity: 0.9 }}>
|
value={winnerUserId || ""}
|
||||||
Keine Spieler gefunden (Admin wird nicht angezeigt).
|
onChange={(e) => setWinnerUserId(e.target.value || "")}
|
||||||
</div>
|
style={{ ...styles.input, flex: 1 }}
|
||||||
) : (
|
>
|
||||||
<select
|
<option value="">— kein Sieger —</option>
|
||||||
value={winnerUserId || ""}
|
{members.map((m) => (
|
||||||
onChange={(e) => setWinnerUserId(e.target.value || null)}
|
<option key={m.id} value={m.id}>
|
||||||
style={styles.input}
|
{m.email}
|
||||||
>
|
</option>
|
||||||
<option value="">— kein Sieger gesetzt —</option>
|
))}
|
||||||
{players.map((p) => (
|
</select>
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.email}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
<button onClick={onSave} style={styles.primaryBtn}>
|
||||||
<button onClick={() => setWinnerUserId(null)} style={styles.secondaryBtn}>
|
Speichern
|
||||||
Leeren
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button onClick={onSave} style={styles.primaryBtn} disabled={!hasPlayers}>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||||
Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar.
|
Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user