dev #4

Merged
nessi merged 25 commits from dev into main 2026-02-06 13:36:47 +00:00
10 changed files with 564 additions and 147 deletions
Showing only changes of commit d0f65b856e - Show all commits

View File

@@ -1,9 +1,14 @@
import os
import random
import string
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Role
from .models import User, Entry, Category, Role, Game, GameMember
from .security import hash_password
from .routes.auth import router as auth_router
from .routes.admin import router as admin_router
@@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet")
# Intern: Frontend läuft auf :8081
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"],
allow_origins=[
"http://localhost:8081",
"http://127.0.0.1:8081",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -24,9 +32,71 @@ app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(games_router)
def _rand_join_code(n: int = 6) -> str:
# digits only (kahoot style)
return "".join(random.choice(string.digits) for _ in range(n))
def _auto_migrate(db: Session):
"""
Very small, pragmatic auto-migration (no alembic).
- creates missing tables via create_all
- adds missing columns via ALTER TABLE (best effort)
"""
# Users.theme_key
try:
db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'"))
db.commit()
except Exception:
db.rollback()
# Games.join_code + winner_user_id
try:
db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR"))
db.commit()
except Exception:
db.rollback()
try:
db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR"))
db.commit()
except Exception:
db.rollback()
# SheetState.chip_code
try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR"))
db.commit()
except Exception:
db.rollback()
# Ensure unique index for join_code (best effort)
try:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)"))
db.commit()
except Exception:
db.rollback()
# Backfill join_code for existing games
games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711
if games:
used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]])
for g in games:
code = _rand_join_code()
while code in used:
code = _rand_join_code()
g.join_code = code
used.add(code)
db.commit()
# Backfill membership: ensure owner is member
all_games = db.query(Game).all()
for g in all_games:
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first()
if not exists:
db.add(GameMember(game_id=g.id, user_id=g.owner_user_id))
db.commit()
def seed_entries(db: Session):
# Du kannst hier deine HP-Edition Einträge reinschreiben.
# (Für rein private Nutzung ok öffentlich würde ichs generisch machen.)
if db.query(Entry).count() > 0:
return
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"]
@@ -46,14 +116,24 @@ def ensure_admin(db: Session):
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first()
if not u:
db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value))
db.add(
User(
email=admin_email,
password_hash=hash_password(admin_pw),
role=Role.admin.value,
theme_key="default",
)
)
db.commit()
@app.on_event("startup")
def on_startup():
# create new tables
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
_auto_migrate(db)
ensure_admin(db)
seed_entries(db)
finally:

View File

@@ -1,7 +1,7 @@
import enum
import uuid
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .db import Base
@@ -21,14 +21,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)
# UI preferences (persisted server-side)
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)
name: Mapped[str] = mapped_column(String)
seed: Mapped[int] = mapped_column(Integer)
# "Kahoot"-style join code
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
# Winner (shared for the game)
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"),
)
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())
class Entry(Base):
@@ -46,6 +71,10 @@ class SheetState(Base):
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)
entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True)
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed
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)

View File

@@ -1,8 +1,16 @@
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 verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password
from ..security import (
verify_password,
make_session_value,
set_session,
clear_session,
get_session_user_id,
hash_password,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -10,11 +18,11 @@ router = APIRouter(prefix="/auth", tags=["auth"])
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()
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
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}
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
@router.post("/logout")
def logout(resp: Response):
@@ -29,8 +37,8 @@ def me(req: Request, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == uid).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role}
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)
@@ -41,7 +49,7 @@ 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()
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
if not user:
raise HTTPException(status_code=401, detail="not logged in")
@@ -49,4 +57,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
db.add(user)
db.commit()
return {"ok": True}
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")
theme_key = (data.get("theme_key") or "").strip()
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
if not user:
raise HTTPException(status_code=401, detail="not logged in")
user.theme_key = theme_key
db.add(user)
db.commit()
return {"ok": True, "theme_key": user.theme_key}

View File

@@ -1,13 +1,16 @@
import hashlib, random
import hashlib
import random
import string
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Game, Entry, SheetState, Category
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"])
def require_user(req: Request, db: Session):
def require_user(req: Request, db: Session) -> str:
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
@@ -17,30 +20,139 @@ 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")
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
uid = require_user(req, db)
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:
raise HTTPException(403, "not a member of this game")
return uid, 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)
g = Game(owner_user_id=uid, name=name, seed=seed)
db.add(g); db.commit()
return {"id": g.id, "name": g.name}
join_code = _new_unique_join_code(db)
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
db.add(g)
db.commit()
# creator becomes member
db.add(GameMember(game_id=g.id, user_id=uid))
db.commit()
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:
raise HTTPException(400, "code required")
g = db.query(Game).filter(Game.join_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()
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 = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games]
games = (
db.query(Game)
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid)
.order_by(Game.created_at.desc())
.all()
)
out = []
for g in games:
winner = 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})
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
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}
@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)
# only non-admin users (admin doesn't play)
players = (
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
.order_by(User.email.asc())
.all()
)
return [{"id": u.id, "email": u.email} for u in players]
@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)
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")
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")
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 = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
if not g:
raise HTTPException(404, "game not found")
uid, g = require_member(req, db, game_id)
entries = db.query(Entry).all()
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all()
states = (
db.query(SheetState)
.filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid)
.all()
)
state_map = {st.entry_id: st for st in states}
out = {"suspect": [], "item": [], "location": []}
@@ -51,6 +163,7 @@ 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,
"order": stable_order(g.seed, uid, e.id),
}
out[e.category].append(item)
@@ -65,19 +178,22 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
@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 = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
if not g:
raise HTTPException(404, "game not found")
uid, g = require_member(req, db, game_id)
status = data.get("status")
note_tag = data.get("note_tag")
chip_code = data.get("chip_code")
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")
st = db.query(SheetState).filter(
SheetState.game_id == g.id,
SheetState.owner_user_id == uid,
@@ -85,13 +201,24 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
).first()
if not st:
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=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
db.commit()
return {"ok": True}

View File

@@ -1,16 +1,13 @@
// src/App.jsx
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag";
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles";
import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage";
@@ -23,6 +20,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";
export default function App() {
useHpGlobalStyles();
@@ -39,8 +37,10 @@ export default function App() {
const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null);
// Winner (per game)
const [winnerName, setWinnerName] = useState("");
// Game meta / players / winner
const [gameMeta, setGameMeta] = useState(null);
const [players, setPlayers] = useState([]);
const [winnerUserId, setWinnerUserId] = useState(null);
// Modals
const [helpOpen, setHelpOpen] = useState(false);
@@ -60,13 +60,20 @@ export default function App() {
const [designOpen, setDesignOpen] = useState(false);
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
// Join game
const [joinOpen, setJoinOpen] = 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);
// Theme pro User laden & anwenden
const tk = loadThemeKey(m?.email);
const tk = m?.theme_key || DEFAULT_THEME_KEY;
setThemeKey(tk);
applyTheme(tk);
@@ -82,6 +89,19 @@ export default function App() {
setSheet(sh);
};
const reloadMeta = async () => {
if (!gameId) return;
const meta = await api(`/games/${gameId}/meta`);
setGameMeta(meta);
setWinnerUserId(meta?.winner?.id || null);
};
const reloadPlayers = async () => {
if (!gameId) return;
const ps = await api(`/games/${gameId}/players`);
setPlayers(ps);
};
// ===== Effects =====
// Dropdown outside click
@@ -106,19 +126,13 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// load sheet + winner when game changes
// load sheet/meta when game changes
useEffect(() => {
(async () => {
if (!gameId) return;
try {
await reloadSheet();
} catch {
// ignore
}
// Sieger pro Game aus localStorage laden
setWinnerName(getWinnerLS(gameId));
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
} catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]);
@@ -138,7 +152,9 @@ export default function App() {
setGames([]);
setGameId(null);
setSheet(null);
setWinnerName("");
setGameMeta(null);
setPlayers([]);
setWinnerUserId(null);
};
// ===== Password change =====
@@ -184,10 +200,19 @@ export default function App() {
setUserMenuOpen(false);
};
const selectTheme = (key) => {
const selectTheme = async (key) => {
setThemeKey(key);
applyTheme(key);
saveThemeKey(me?.email, key);
try {
await api("/auth/theme", {
method: "PATCH",
body: JSON.stringify({ theme_key: key }),
});
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
} catch {
// ignore; UI already switched
}
};
// ===== Game actions =====
@@ -196,29 +221,34 @@ export default function App() {
method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
});
const gs = await api("/games");
setGames(gs);
setGameId(g.id);
// Neues Spiel -> Sieger leer
clearWinnerLS(g.id);
setWinnerName("");
};
// ===== Winner actions =====
const saveWinner = () => {
const openJoinModal = () => {
setJoinOpen(true);
setUserMenuOpen(false);
};
const joinGame = async (code) => {
const res = await api("/games/join", {
method: "POST",
body: JSON.stringify({ code }),
});
const gs = await api("/games");
setGames(gs);
setGameId(res?.game?.id || null);
};
// ===== Winner actions (shared per game) =====
const saveWinner = async () => {
if (!gameId) return;
const v = (winnerName || "").trim();
if (!v) {
clearWinnerLS(gameId);
setWinnerName("");
return;
}
setWinnerLS(gameId, v);
setWinnerName(v);
await api(`/games/${gameId}/winner`, {
method: "PATCH",
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
});
await reloadMeta();
};
// ===== Sheet actions =====
@@ -248,8 +278,6 @@ 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 }),
@@ -265,12 +293,10 @@ 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" }),
body: JSON.stringify({ note_tag: "s", chip_code: chip }),
});
} finally {
await reloadSheet();
@@ -287,8 +313,6 @@ export default function App() {
setChipOpen(false);
setChipEntry(null);
clearChipLS(gameId, entry.entry_id);
try {
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
@@ -303,8 +327,7 @@ export default function App() {
const t = entry.note_tag;
if (!t) return "—";
if (t === "s") {
const chip = getChipLS(gameId, entry.entry_id);
return chip ? `s.${chip}` : "s";
return entry.chip_code ? `s.${entry.chip_code}` : "s";
}
return t;
};
@@ -332,6 +355,8 @@ export default function App() {
]
: [];
const winnerObj = gameMeta?.winner || null;
return (
<div style={styles.page}>
<div style={styles.bgFixed} aria-hidden="true">
@@ -345,6 +370,7 @@ export default function App() {
setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal}
openDesignModal={openDesignModal}
openJoinModal={openJoinModal}
doLogout={doLogout}
newGame={newGame}
/>
@@ -355,13 +381,13 @@ export default function App() {
games={games}
gameId={gameId}
setGameId={setGameId}
joinCode={currentGame?.join_code || ""}
onOpenHelp={() => setHelpOpen(true)}
/>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
{winnerObj && <WinnerBadge winnerEmail={winnerObj.email} />}
{/* Sieger Badge: nur wenn gesetzt */}
<WinnerBadge winner={(winnerName || "").trim()} />
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => (
@@ -377,8 +403,13 @@ export default function App() {
))}
</div>
{/* Sieger ganz unten */}
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} />
{/* Sieger (shared per Spiel) */}
<WinnerCard
players={players}
winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId}
onSave={saveWinner}
/>
<div style={{ height: 24 }} />
</div>
@@ -399,12 +430,21 @@ export default function App() {
open={designOpen}
onClose={() => setDesignOpen(false)}
themeKey={themeKey}
onSelect={(k) => {
selectTheme(k);
onSelect={async (k) => {
await selectTheme(k);
setDesignOpen(false);
}}
/>
<JoinGameModal
open={joinOpen}
onClose={() => setJoinOpen(false)}
onJoin={async (code) => {
await joinGame(code);
setJoinOpen(false);
}}
/>
<ChipModal
chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash}

View File

@@ -1,13 +1,9 @@
// 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,
onOpenHelp,
}) {
export default function GamePickerCard({ games, gameId, setGameId, joinCode, onOpenHelp }) {
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
@@ -30,6 +26,19 @@ export default function GamePickerCard({
Hilfe
</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>
)}
</div>
</div>
);

View File

@@ -0,0 +1,74 @@
// src/components/JoinGameModal.jsx
import React, { useEffect, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function JoinGameModal({ open, onClose, onJoin }) {
const [code, setCode] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
setCode("");
setMsg("");
setBusy(false);
}, [open]);
if (!open) return null;
const doJoin = async () => {
const c = (code || "").trim();
if (!c) return setMsg("❌ Bitte Code eingeben.");
setBusy(true);
setMsg("");
try {
await onJoin(c);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setBusy(false);
}
};
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 beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="z.B. 123456"
style={styles.input}
inputMode="numeric"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") doJoin();
}}
/>
{msg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={onClose} style={styles.secondaryBtn} disabled={busy}>
Abbrechen
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={busy}>
{busy ? "Beitreten..." : "Beitreten"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Der Spiel-Code steht beim Host unter dem Spiel-Dropdown.
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,16 +8,19 @@ export default function TopBar({
setUserMenuOpen,
openPwModal,
openDesignModal,
openJoinModal,
doLogout,
newGame,
}) {
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>
{/* 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ü">
@@ -28,13 +31,14 @@ 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 rgba(233,216,166,0.12)",
borderBottom: `1px solid ${stylesTokens.goldLine}`,
}}
>
{me.email}
@@ -44,10 +48,26 @@ export default function TopBar({
Passwort setzen
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
<button
onClick={() => {
setUserMenuOpen(false);
openDesignModal();
}}
style={styles.userDropdownItem}
>
Design ändern
</button>
<button
onClick={() => {
setUserMenuOpen(false);
openJoinModal();
}}
style={styles.userDropdownItem}
>
Spiel beitreten
</button>
<div style={styles.userDropdownDivider} />
<button
@@ -64,7 +84,7 @@ export default function TopBar({
</div>
<button onClick={newGame} style={styles.primaryBtn}>
New Game
Neues Spiel
</button>
</div>
</div>

View File

@@ -1,43 +1,27 @@
// src/components/WinnerBadge.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function WinnerBadge({ winner }) {
const w = (winner || "").trim();
if (!w) return null;
export default function WinnerBadge({ winnerEmail }) {
if (!winnerEmail) return null;
return (
<div style={{ marginTop: 14 }}>
<div
style={{
...styles.card,
padding: 12,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div>
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
</div>
<div
style={{
padding: "8px 12px",
borderRadius: 999,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textGold,
fontWeight: 1000,
whiteSpace: "nowrap",
}}
>
Gewonnen
</div>
</div>
<div
style={{
marginTop: 14,
padding: "12px 14px",
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
display: "flex",
gap: 10,
alignItems: "center",
}}
>
<span style={{ fontSize: 16 }}>🏆</span>
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
</div>
);
}

View File

@@ -1,31 +1,55 @@
// src/components/WinnerCard.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function WinnerCard({ value, setValue, onSave }) {
/**
* 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;
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.cardBody}>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Name des Siegers"
style={{ ...styles.input, flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter") onSave();
}}
/>
<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>
)}
<button onClick={onSave} style={styles.primaryBtn} title="Speichern">
Speichern
</button>
</div>
<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>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim }}>
Wird pro Spiel lokal gespeichert.
<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>
</div>
</div>