dev #4

Merged
nessi merged 25 commits from dev into main 2026-02-06 13:36:47 +00:00
9 changed files with 488 additions and 268 deletions
Showing only changes of commit 4669d1f8c4 - Show all commits

View File

@@ -1,19 +1,30 @@
import enum
import uuid
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
from sqlalchemy import (
String,
Boolean,
DateTime,
ForeignKey,
Integer,
SmallInteger,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .db import Base
class Role(str, enum.Enum):
admin = "admin"
user = "user"
class Category(str, enum.Enum):
suspect = "suspect"
item = "item"
location = "location"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
@@ -21,40 +32,39 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String)
role: Mapped[str] = mapped_column(String, default=Role.user.value)
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
# UI preferences (persisted server-side)
# NEW: Theme im Userprofil (damit es auf anderen Geräten mitkommt)
theme_key: Mapped[str] = mapped_column(String, default="default")
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Game(Base):
__tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
# Creator/owner (for audit), membership controls access
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
# NEW: Host (nur Host darf Winner setzen)
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String)
seed: Mapped[int] = mapped_column(Integer)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
# "Kahoot"-style join code
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
# NEW: Join-Code (Kahoot-Style)
code: Mapped[str] = mapped_column(String, unique=True, index=True)
# Winner (shared for the game)
# NEW: Winner (aus Users, nicht Freitext)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class GameMember(Base):
__tablename__ = "game_members"
__table_args__ = (
UniqueConstraint("game_id", "user_id", name="uq_game_member"),
)
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_member"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Entry(Base):
__tablename__ = "entries"
@@ -62,11 +72,13 @@ class Entry(Base):
category: Mapped[str] = mapped_column(String, index=True)
label: Mapped[str] = mapped_column(String)
class SheetState(Base):
__tablename__ = "sheet_state"
__table_args__ = (
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
@@ -75,6 +87,6 @@ class SheetState(Base):
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
# Frontend "s.XX" chip selection (persisted)
chip_code: Mapped[str | None] = mapped_column(String, nullable=True)
# NEW: Chip persistieren (statt LocalStorage)
chip: Mapped[str | None] = mapped_column(String, nullable=True)

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User
from ..security import (
@@ -14,21 +13,25 @@ from ..security import (
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
email = (data.get("email") or "").lower().strip()
password = data.get("password") or ""
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.email == email, User.disabled == False).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials")
set_session(resp, make_session_value(user.id))
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
@router.post("/logout")
def logout(resp: Response):
clear_session(resp)
return {"ok": True}
@router.get("/me")
def me(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
@@ -39,6 +42,7 @@ def me(req: Request, db: Session = Depends(get_db)):
raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key}
@router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
@@ -49,19 +53,18 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
user.password_hash = hash_password(password)
db.add(user)
db.commit()
return {"ok": True}
@router.patch("/theme")
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
"""Persist user design selection server-side."""
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
@@ -70,7 +73,7 @@ def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
if not theme_key:
raise HTTPException(status_code=400, detail="theme_key required")
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")

View File

@@ -1,151 +1,193 @@
import hashlib
import random
import string
import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"])
def require_user(req: Request, db: Session) -> str:
def require_user(req: Request, db: Session):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
return uid
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
s = f"{seed}:{user_id}:{entry_id}".encode()
return hashlib.sha256(s).hexdigest()
def _rand_join_code(n: int = 6) -> str:
return "".join(random.choice(string.digits) for _ in range(n))
def _new_unique_join_code(db: Session) -> str:
for _ in range(50):
code = _rand_join_code()
if not db.query(Game).filter(Game.join_code == code).first():
return code
raise HTTPException(500, "failed to generate join code")
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
uid = require_user(req, db)
def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex:
return
db.add(GameMember(game_id=game_id, user_id=user_id))
db.commit()
def require_game_member(db: Session, game_id: str, user_id: str) -> Game:
g = db.query(Game).filter(Game.id == game_id).first()
if not g:
raise HTTPException(404, "game not found")
m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
if not m:
mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if not mem:
raise HTTPException(403, "not a member of this game")
return uid, g
return g
@router.post("")
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
name = data.get("name") or "Neues Spiel"
seed = random.randint(1, 2_000_000_000)
join_code = _new_unique_join_code(db)
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
# unique code
code = gen_code()
while db.query(Game).filter(Game.code == code).first():
code = gen_code()
g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None)
db.add(g)
db.commit()
# creator becomes member
db.add(GameMember(game_id=g.id, user_id=uid))
db.commit()
# creator joins automatically
ensure_member(db, g.id, uid)
return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
return {"id": g.id, "name": g.name, "join_code": g.join_code}
@router.post("/join")
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
code = (data.get("code") or "").strip()
if not code or len(code) < 4:
code = (data.get("code") or "").strip().upper()
if not code:
raise HTTPException(400, "code required")
g = db.query(Game).filter(Game.join_code == code).first()
g = db.query(Game).filter(Game.code == code).first()
if not g:
raise HTTPException(404, "game not found")
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
if not exists:
db.add(GameMember(game_id=g.id, user_id=uid))
db.commit()
ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}}
@router.get("")
def list_games(req: Request, db: Session = Depends(get_db)):
uid = require_user(req, db)
games = (
# list games where user is member
q = (
db.query(Game)
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid)
.order_by(Game.created_at.desc())
.all()
)
games = q.all()
# winner email (optional)
out = []
for g in games:
winner = None
winner_email = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
if wu:
winner = {"id": wu.id, "email": wu.email}
out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner})
winner_email = wu.email if wu else None
out.append(
{
"id": g.id,
"name": g.name,
"seed": g.seed,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
}
)
return out
@router.get("/{game_id}/meta")
def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
winner = None
@router.get("/{game_id}")
def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
winner_email = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
if wu:
winner = {"id": wu.id, "email": wu.email}
return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner}
winner_email = wu.email if wu else None
@router.get("/{game_id}/players")
def list_players(req: Request, game_id: str, db: Session = Depends(get_db)):
_uid, g = require_member(req, db, game_id)
return {
"id": g.id,
"name": g.name,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
}
# only non-admin users (admin doesn't play)
players = (
@router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
_g = require_game_member(db, game_id, uid)
# return only "user" role (admin excluded)
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
return [{"id": u.id, "email": u.email} for u in players]
return [{"id": u.id, "email": u.email} for u in members]
@router.patch("/{game_id}/winner")
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
_uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
# only host can set winner
if g.host_user_id != uid:
raise HTTPException(403, "only host can set winner")
winner_user_id = data.get("winner_user_id")
if winner_user_id is not None:
# must be a member + non-admin
u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712
if not u or u.role != Role.user.value:
raise HTTPException(400, "invalid winner_user_id")
if winner_user_id is None:
g.winner_user_id = None
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": None}
member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first()
if not member:
raise HTTPException(400, "winner is not in this game")
# must be a member AND role=user
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
if not member:
raise HTTPException(400, "winner must be a member of the game")
u = db.query(User).filter(User.id == winner_user_id).first()
if not u or u.role != Role.user.value or u.disabled:
raise HTTPException(400, "invalid winner")
g.winner_user_id = winner_user_id
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": g.winner_user_id}
@router.get("/{game_id}/sheet")
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
entries = db.query(Entry).all()
states = (
@@ -163,12 +205,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
"label": e.label,
"status": st.status if st else 0,
"note_tag": st.note_tag if st else None,
"chip_code": st.chip_code if st else None,
"chip": st.chip if st else None, # NEW
"order": stable_order(g.seed, uid, e.id),
}
out[e.category].append(item)
# sort within category
for k in out:
out[k].sort(key=lambda x: x["order"])
for i in out[k]:
@@ -176,49 +217,58 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
return out
@router.patch("/{game_id}/sheet/{entry_id}")
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
uid, g = require_member(req, db, game_id)
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
status = data.get("status")
note_tag = data.get("note_tag")
chip_code = data.get("chip_code")
chip = data.get("chip")
if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2, 3):
raise HTTPException(400, "invalid status")
# chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's')
if chip_code is not None:
if not isinstance(chip_code, str) or len(chip_code) > 16:
raise HTTPException(400, "invalid chip_code")
if chip is not None:
chip = (chip or "").strip().upper()
if chip == "":
chip = None
if chip is not None:
if len(chip) > 8:
raise HTTPException(400, "invalid chip")
st = db.query(SheetState).filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id
).first()
st = (
db.query(SheetState)
.filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id,
)
.first()
)
if not st:
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None)
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None)
db.add(st)
if status is not None:
st.status = status
if "note_tag" in data:
st.note_tag = note_tag
# if leaving 's', clear chip
if note_tag != "s":
st.chip_code = None
if "chip_code" in data:
# chip_code is only meaningful when note_tag is 's'
effective_tag = st.note_tag
if effective_tag != "s":
raise HTTPException(400, "chip_code requires note_tag 's'")
st.chip_code = chip_code
# wenn note_tag zurück auf null -> chip auch löschen
if note_tag is None:
st.chip = None
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
if "chip" in data:
if st.note_tag == "s":
st.chip = chip
else:
st.chip = None
db.commit()
return {"ok": True}

View File

@@ -1,12 +1,11 @@
// src/App.jsx
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag";
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import AdminPanel from "./components/AdminPanel";
@@ -20,7 +19,7 @@ import SheetSection from "./components/SheetSection";
import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge";
import JoinGameModal from "./components/JoinGameModal";
import NewGameModal from "./components/NewGameModal";
export default function App() {
useHpGlobalStyles();
@@ -37,17 +36,17 @@ export default function App() {
const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null);
// Game meta / players / winner
const [gameMeta, setGameMeta] = useState(null);
const [players, setPlayers] = useState([]);
const [winnerUserId, setWinnerUserId] = useState(null);
// Game meta
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
const [members, setMembers] = useState([]);
// Winner selection (host only)
const [winnerUserId, setWinnerUserId] = useState("");
// Modals
const [helpOpen, setHelpOpen] = useState(false);
const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
@@ -60,15 +59,9 @@ export default function App() {
const [designOpen, setDesignOpen] = useState(false);
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
// Join game
const [joinOpen, setJoinOpen] = useState(false);
// New Game Modal
const [newGameOpen, setNewGameOpen] = useState(false);
const currentGame = useMemo(
() => games.find((g) => String(g.id) === String(gameId)) || null,
[games, gameId]
);
// ===== Data loaders =====
const load = async () => {
const m = await api("/auth/me");
setMe(m);
@@ -89,21 +82,16 @@ export default function App() {
setSheet(sh);
};
const reloadMeta = async () => {
const loadGameMeta = async () => {
if (!gameId) return;
const meta = await api(`/games/${gameId}/meta`);
const meta = await api(`/games/${gameId}`);
setGameMeta(meta);
setWinnerUserId(meta?.winner?.id || null);
};
setWinnerUserId(meta?.winner_user_id || "");
const reloadPlayers = async () => {
if (!gameId) return;
const ps = await api(`/games/${gameId}/players`);
setPlayers(ps);
const mem = await api(`/games/${gameId}/members`);
setMembers(mem);
};
// ===== Effects =====
// Dropdown outside click
useEffect(() => {
const onDown = (e) => {
@@ -114,24 +102,23 @@ export default function App() {
return () => document.removeEventListener("mousedown", onDown);
}, [userMenuOpen]);
// initial load (try session)
// initial load
useEffect(() => {
(async () => {
try {
await load();
} catch {
// not logged in
}
} catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// load sheet/meta when game changes
// on game change
useEffect(() => {
(async () => {
if (!gameId) return;
try {
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
await reloadSheet();
await loadGameMeta();
} catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -153,11 +140,11 @@ export default function App() {
setGameId(null);
setSheet(null);
setGameMeta(null);
setPlayers([]);
setWinnerUserId(null);
setMembers([]);
setWinnerUserId("");
};
// ===== Password change =====
// ===== Password =====
const openPwModal = () => {
setPwMsg("");
setPw1("");
@@ -194,7 +181,7 @@ export default function App() {
}
};
// ===== Theme actions =====
// ===== Theme =====
const openDesignModal = () => {
setDesignOpen(true);
setUserMenuOpen(false);
@@ -209,26 +196,24 @@ export default function App() {
method: "PATCH",
body: JSON.stringify({ theme_key: key }),
});
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
} catch {
// ignore; UI already switched
// theme locally already applied; ignore backend error
}
};
// ===== Game actions =====
const newGame = async () => {
// ===== New game flow =====
const createGame = async () => {
const g = await api("/games", {
method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
});
const gs = await api("/games");
setGames(gs);
setGameId(g.id);
};
const openJoinModal = () => {
setJoinOpen(true);
setUserMenuOpen(false);
// meta/members will load via gameId effect
return g; // includes code
};
const joinGame = async (code) => {
@@ -236,19 +221,20 @@ export default function App() {
method: "POST",
body: JSON.stringify({ code }),
});
const gs = await api("/games");
setGames(gs);
setGameId(res?.game?.id || null);
setGameId(res.id);
};
// ===== Winner actions (shared per game) =====
// ===== Winner =====
const saveWinner = async () => {
if (!gameId) return;
await api(`/games/${gameId}/winner`, {
method: "PATCH",
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
});
await reloadMeta();
await loadGameMeta();
};
// ===== Sheet actions =====
@@ -278,9 +264,11 @@ export default function App() {
return;
}
if (next === null) clearChipLS(gameId, entry.entry_id);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: next }),
body: JSON.stringify({ note_tag: next, chip: null }),
});
await reloadSheet();
@@ -293,10 +281,12 @@ export default function App() {
setChipOpen(false);
setChipEntry(null);
setChipLS(gameId, entry.entry_id, chip);
try {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: "s", chip_code: chip }),
body: JSON.stringify({ note_tag: "s", chip }),
});
} finally {
await reloadSheet();
@@ -313,10 +303,12 @@ export default function App() {
setChipOpen(false);
setChipEntry(null);
clearChipLS(gameId, entry.entry_id);
try {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: null }),
body: JSON.stringify({ note_tag: null, chip: null }),
});
} finally {
await reloadSheet();
@@ -326,10 +318,14 @@ export default function App() {
const displayTag = (entry) => {
const t = entry.note_tag;
if (!t) return "—";
if (t === "s") {
return entry.chip_code ? `s.${entry.chip_code}` : "s";
// Prefer backend chip, fallback localStorage
const chip = entry.chip || getChipLS(gameId, entry.entry_id);
return chip ? `s.${chip}` : "s";
}
return t;
return t; // i oder m
};
// ===== Login page =====
@@ -355,7 +351,7 @@ export default function App() {
]
: [];
const winnerObj = gameMeta?.winner || null;
const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id);
return (
<div style={styles.page}>
@@ -370,9 +366,8 @@ export default function App() {
setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal}
openDesignModal={openDesignModal}
openJoinModal={openJoinModal}
doLogout={doLogout}
newGame={newGame}
onOpenNewGame={() => setNewGameOpen(true)}
/>
{me.role === "admin" && <AdminPanel />}
@@ -381,11 +376,11 @@ export default function App() {
games={games}
gameId={gameId}
setGameId={setGameId}
joinCode={currentGame?.join_code || ""}
onOpenHelp={() => setHelpOpen(true)}
/>
{winnerObj && <WinnerBadge winnerEmail={winnerObj.email} />}
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
<WinnerBadge winnerEmail={gameMeta?.winner_email || ""} />
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
@@ -403,9 +398,10 @@ export default function App() {
))}
</div>
{/* Sieger (shared per Spiel) */}
{/* Host-only Winner Auswahl */}
<WinnerCard
players={players}
isHost={isHost}
members={members}
winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId}
onSave={saveWinner}
@@ -430,19 +426,17 @@ export default function App() {
open={designOpen}
onClose={() => setDesignOpen(false)}
themeKey={themeKey}
onSelect={async (k) => {
await selectTheme(k);
onSelect={(k) => {
selectTheme(k);
setDesignOpen(false);
}}
/>
<JoinGameModal
open={joinOpen}
onClose={() => setJoinOpen(false)}
onJoin={async (code) => {
await joinGame(code);
setJoinOpen(false);
}}
<NewGameModal
open={newGameOpen}
onClose={() => setNewGameOpen(false)}
onCreate={createGame}
onJoin={joinGame}
/>
<ChipModal

View File

@@ -1,9 +1,8 @@
// src/components/GamePickerCard.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function GamePickerCard({ games, gameId, setGameId, joinCode, onOpenHelp }) {
export default function GamePickerCard({ games, gameId, setGameId, onOpenHelp }) {
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
@@ -17,7 +16,7 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
{g.name} {g.code ? `${g.code}` : ""}
</option>
))}
</select>
@@ -27,18 +26,16 @@ export default function GamePickerCard({ games, gameId, setGameId, joinCode, onO
</button>
</div>
{!!joinCode && (
<div
style={{
padding: "0 12px 12px",
fontSize: 12,
opacity: 0.85,
color: stylesTokens.textDim,
}}
>
Spiel-Code: <b style={{ color: stylesTokens.textGold }}>{joinCode}</b>
</div>
)}
{/* kleine Code Zeile unter dem Picker (optional nice) */}
{(() => {
const cur = games.find((x) => x.id === gameId);
if (!cur?.code) return null;
return (
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
</div>
);
})()}
</div>
</div>
);

View 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>
);
}

View File

@@ -8,22 +8,27 @@ export default function TopBar({
setUserMenuOpen,
openPwModal,
openDesignModal,
openJoinModal,
doLogout,
newGame,
onOpenNewGame, // NEW
}) {
return (
<div style={styles.topBar}>
{/* LINKS */}
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
Notizbogen
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{me.email}
</div>
</div>
{/* RECHTS */}
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}>
<button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü">
<button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16 }}>👤</span>
<span>User</span>
<span style={{ opacity: 0.7 }}></span>
@@ -31,14 +36,13 @@ export default function TopBar({
{userMenuOpen && (
<div style={styles.userDropdown}>
{/* Email Info */}
<div
style={{
padding: "10px 12px",
fontSize: 13,
opacity: 0.85,
color: stylesTokens.textDim,
borderBottom: `1px solid ${stylesTokens.goldLine}`,
borderBottom: "1px solid rgba(233,216,166,0.12)",
}}
>
{me.email}
@@ -48,26 +52,10 @@ export default function TopBar({
Passwort setzen
</button>
<button
onClick={() => {
setUserMenuOpen(false);
openDesignModal();
}}
style={styles.userDropdownItem}
>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
</button>
<button
onClick={() => {
setUserMenuOpen(false);
openJoinModal();
}}
style={styles.userDropdownItem}
>
Spiel beitreten
</button>
<div style={styles.userDropdownDivider} />
<button
@@ -83,8 +71,8 @@ export default function TopBar({
)}
</div>
<button onClick={newGame} style={styles.primaryBtn}>
Neues Spiel
<button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game
</button>
</div>
</div>

View File

@@ -8,20 +8,29 @@ export default function WinnerBadge({ winnerEmail }) {
<div
style={{
marginTop: 14,
padding: "12px 14px",
padding: "10px 12px",
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(6px)",
display: "flex",
gap: 10,
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<span style={{ fontSize: 16 }}>🏆</span>
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger:
<span style={{ color: stylesTokens.textGold }}>{" "}{winnerEmail}</span>
</div>
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt
</div>
</div>
);
}

View File

@@ -1,55 +1,42 @@
// src/components/WinnerCard.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
/**
* props:
* - players: [{id,email}]
* - winnerUserId: string|null
* - setWinnerUserId: fn
* - onSave: fn (async ok)
*/
export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) {
const hasPlayers = Array.isArray(players) && players.length > 0;
export default function WinnerCard({
isHost,
members,
winnerUserId,
setWinnerUserId,
onSave,
}) {
if (!isHost) return null;
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={{ padding: 12, display: "grid", gap: 10 }}>
{!hasPlayers ? (
<div style={{ color: stylesTokens.textDim, opacity: 0.9 }}>
Keine Spieler gefunden (Admin wird nicht angezeigt).
</div>
) : (
<select
value={winnerUserId || ""}
onChange={(e) => setWinnerUserId(e.target.value || null)}
style={styles.input}
>
<option value=""> kein Sieger gesetzt </option>
{players.map((p) => (
<option key={p.id} value={p.id}>
{p.email}
</option>
))}
</select>
)}
<div style={styles.cardBody}>
<select
value={winnerUserId || ""}
onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }}
>
<option value=""> kein Sieger </option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.email}
</option>
))}
</select>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setWinnerUserId(null)} style={styles.secondaryBtn}>
Leeren
</button>
<button onClick={onSave} style={styles.primaryBtn} disabled={!hasPlayers}>
Speichern
</button>
</div>
<button onClick={onSave} style={styles.primaryBtn}>
Speichern
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar.
</div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
</div>
</div>
</div>