diff --git a/backend/app/models.py b/backend/app/models.py index 7191f91..8508cf7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 45a2ec7..a0ff439 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -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") diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 38b772b..71c07b7 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -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} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0930182..bb7cc5f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (