dev #4
@@ -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