Added faster polling interval (700ms) for pre-game lobby to show joining players in near real-time, while keeping slower 2.5s refresh for active games. Implemented pending request flag to prevent concurrent API calls from overlapping. Added `cache: "no-store"` to fetch configuration to ensure fresh data on every request. Updated useEffect dependency array to re-initialize polling when game start state changes.
796 lines
22 KiB
React
796 lines
22 KiB
React
import React, { useEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import WinnerCelebration from "./components/WinnerCelebration";
|
||
|
||
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 { stylesTokens } from "./styles/theme";
|
||
|
||
import AdminPanel from "./components/AdminPanel";
|
||
import LoginPage from "./components/LoginPage";
|
||
import TopBar from "./components/TopBar";
|
||
import PasswordModal from "./components/PasswordModal";
|
||
import ChipModal from "./components/ChipModal";
|
||
import HelpModal from "./components/HelpModal";
|
||
import GamePickerCard from "./components/GamePickerCard";
|
||
import SheetSection from "./components/SheetSection";
|
||
import DesignModal from "./components/DesignModal";
|
||
import WinnerCard from "./components/WinnerCard";
|
||
import WinnerBadge from "./components/WinnerBadge";
|
||
import NewGameModal from "./components/NewGameModal";
|
||
import StatsModal from "./components/StatsModal";
|
||
|
||
export default function App() {
|
||
useHpGlobalStyles();
|
||
|
||
// Auth/Login UI state
|
||
const [me, setMe] = useState(null);
|
||
const [setupRequired, setSetupRequired] = useState(null);
|
||
const [setupEmail, setSetupEmail] = useState("");
|
||
const [setupDisplayName, setSetupDisplayName] = useState("");
|
||
const [setupPassword, setSetupPassword] = useState("");
|
||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState("");
|
||
const [setupError, setSetupError] = useState("");
|
||
const [setupSaving, setSetupSaving] = useState(false);
|
||
const [loginEmail, setLoginEmail] = useState("");
|
||
const [loginPassword, setLoginPassword] = useState("");
|
||
const [showPw, setShowPw] = useState(false);
|
||
|
||
// Game/Sheet state
|
||
const [games, setGames] = useState([]);
|
||
const [gameId, setGameId] = useState(null);
|
||
const [sheet, setSheet] = useState(null);
|
||
const [pulseId, setPulseId] = useState(null);
|
||
|
||
// Game meta
|
||
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
|
||
const [members, setMembers] = useState([]);
|
||
const [gameChips, setGameChips] = 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);
|
||
const [pw1, setPw1] = useState("");
|
||
const [pw2, setPw2] = useState("");
|
||
const [pwMsg, setPwMsg] = useState("");
|
||
const [pwSaving, setPwSaving] = useState(false);
|
||
|
||
// Theme
|
||
const [designOpen, setDesignOpen] = useState(false);
|
||
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
|
||
|
||
// New Game Modal
|
||
const [newGameOpen, setNewGameOpen] = useState(false);
|
||
|
||
// ===== Stats Modal =====
|
||
const [statsOpen, setStatsOpen] = useState(false);
|
||
const [stats, setStats] = useState(null);
|
||
const [statsLoading, setStatsLoading] = useState(false);
|
||
const [statsError, setStatsError] = useState("");
|
||
|
||
// ===== Join Snack (bottom toast) =====
|
||
const [snack, setSnack] = useState("");
|
||
const snackTimerRef = useRef(null);
|
||
|
||
// track members to detect joins
|
||
const lastMemberIdsRef = useRef(new Set());
|
||
const membersBaselineRef = useRef(false);
|
||
|
||
// ===== Winner Celebration =====
|
||
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
||
const [celebrateName, setCelebrateName] = useState("");
|
||
|
||
// baseline per game: beim ersten Meta-Load NICHT feiern
|
||
const winnerBaselineRef = useRef(false);
|
||
const lastWinnerIdRef = useRef(null);
|
||
|
||
const showSnack = (msg) => {
|
||
setSnack(msg);
|
||
if (snackTimerRef.current) clearTimeout(snackTimerRef.current);
|
||
snackTimerRef.current = setTimeout(() => setSnack(""), 1800);
|
||
};
|
||
|
||
const vibrate = (pattern) => {
|
||
try {
|
||
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
||
navigator.vibrate(pattern);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const load = async () => {
|
||
const m = await api("/auth/me");
|
||
setMe(m);
|
||
|
||
const tk = m?.theme_key || DEFAULT_THEME_KEY;
|
||
setThemeKey(tk);
|
||
applyTheme(tk);
|
||
|
||
const gs = await api("/games");
|
||
setGames(gs);
|
||
|
||
if (gs[0] && !gameId) setGameId(gs[0].id);
|
||
};
|
||
|
||
const reloadSheet = async () => {
|
||
if (!gameId) return;
|
||
const sh = await api(`/games/${gameId}/sheet`);
|
||
setSheet(sh);
|
||
};
|
||
|
||
const loadGameMeta = async () => {
|
||
if (!gameId) return;
|
||
|
||
const meta = await api(`/games/${gameId}`);
|
||
setGameMeta(meta);
|
||
setWinnerUserId(meta?.winner_user_id || "");
|
||
|
||
const chips = meta?.started ? await api(`/games/${gameId}/chips`) : [];
|
||
setGameChips(chips || []);
|
||
|
||
const mem = await api(`/games/${gameId}/members`);
|
||
setMembers(mem);
|
||
|
||
// ✅ detect new members (join notifications for everyone)
|
||
try {
|
||
const prev = lastMemberIdsRef.current;
|
||
const nowIds = new Set((mem || []).map((m) => String(m.id)));
|
||
|
||
if (!membersBaselineRef.current) {
|
||
// first load for this game -> set baseline, no notification
|
||
membersBaselineRef.current = true;
|
||
lastMemberIdsRef.current = nowIds;
|
||
return;
|
||
}
|
||
|
||
const added = (mem || []).filter((m) => !prev.has(String(m.id)));
|
||
|
||
if (added.length > 0) {
|
||
const names = added
|
||
.map((m) => ((m.display_name || "").trim() || (m.email || "").trim() || "Jemand"))
|
||
.slice(0, 3);
|
||
|
||
const msg =
|
||
added.length === 1
|
||
? `✨ ${names[0]} ist beigetreten`
|
||
: `✨ ${names.join(", ")} ${added.length > 3 ? `(+${added.length - 3}) ` : ""}sind beigetreten`;
|
||
|
||
showSnack(msg);
|
||
vibrate(25); // dezent & kurz
|
||
}
|
||
|
||
lastMemberIdsRef.current = nowIds;
|
||
} catch {
|
||
// ignore snack errors
|
||
}
|
||
};
|
||
|
||
// Dropdown outside click
|
||
useEffect(() => {
|
||
const onDown = (e) => {
|
||
const root = e.target?.closest?.("[data-user-menu]");
|
||
if (!root) setUserMenuOpen(false);
|
||
};
|
||
if (userMenuOpen) document.addEventListener("mousedown", onDown);
|
||
return () => document.removeEventListener("mousedown", onDown);
|
||
}, [userMenuOpen]);
|
||
|
||
// initial load
|
||
useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
const status = await api("/setup/status");
|
||
setSetupRequired(!!status.setup_required);
|
||
await load();
|
||
} catch {
|
||
// The login/setup screen handles unauthenticated sessions.
|
||
}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// on game change
|
||
useEffect(() => {
|
||
// reset join detection baseline when switching games
|
||
membersBaselineRef.current = false;
|
||
lastMemberIdsRef.current = new Set();
|
||
|
||
// reset winner celebration baseline when switching games
|
||
winnerBaselineRef.current = false;
|
||
lastWinnerIdRef.current = null;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
|
||
(async () => {
|
||
if (!gameId) return;
|
||
try {
|
||
await reloadSheet();
|
||
await loadGameMeta();
|
||
} catch {}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [gameId]);
|
||
|
||
// ✅ Live refresh (Members/Meta) – Lobby schnell, laufendes Spiel genügsamer.
|
||
useEffect(() => {
|
||
if (!me || !gameId) return;
|
||
|
||
let alive = true;
|
||
let pending = false;
|
||
const refreshInterval = gameMeta?.started ? 2500 : 700;
|
||
|
||
const tick = async () => {
|
||
if (pending) return;
|
||
pending = true;
|
||
try {
|
||
await loadGameMeta(); // refresh members + winner meta
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
pending = false;
|
||
}
|
||
};
|
||
|
||
// sofort einmal ziehen
|
||
tick();
|
||
|
||
const id = setInterval(() => {
|
||
if (!alive) return;
|
||
tick();
|
||
}, refreshInterval);
|
||
|
||
return () => {
|
||
alive = false;
|
||
clearInterval(id);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [me?.id, gameId, gameMeta?.started]);
|
||
|
||
useEffect(() => {
|
||
// wid kann auch "" sein (kein Sieger)
|
||
const wid = gameMeta?.winner_user_id ? String(gameMeta.winner_user_id) : "";
|
||
|
||
// Baseline beim ersten Meta-Load setzen – egal ob Winner existiert oder nicht
|
||
if (!winnerBaselineRef.current) {
|
||
winnerBaselineRef.current = true;
|
||
lastWinnerIdRef.current = wid; // kann "" sein
|
||
return;
|
||
}
|
||
|
||
// Nur reagieren, wenn sich wid ändert
|
||
if (lastWinnerIdRef.current !== wid) {
|
||
lastWinnerIdRef.current = wid;
|
||
|
||
// wenn wid leer wird (reset), nicht feiern
|
||
if (!wid) return;
|
||
|
||
const name =
|
||
(gameMeta?.winner_display_name || "").trim() ||
|
||
(gameMeta?.winner_email || "").trim() ||
|
||
"Jemand";
|
||
|
||
setCelebrateName(name);
|
||
setCelebrateOpen(true);
|
||
}
|
||
}, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]);
|
||
|
||
|
||
// ===== Auth actions =====
|
||
const doLogin = async () => {
|
||
await api("/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ email: loginEmail, password: loginPassword }),
|
||
});
|
||
await load();
|
||
};
|
||
|
||
const doSetup = async () => {
|
||
setSetupError("");
|
||
if (setupPassword.length < 8) {
|
||
setSetupError("Password must be at least 8 characters long.");
|
||
return;
|
||
}
|
||
if (setupPassword !== setupPasswordConfirm) {
|
||
setSetupError("Passwords do not match.");
|
||
return;
|
||
}
|
||
|
||
setSetupSaving(true);
|
||
try {
|
||
await api("/setup/admin", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
email: setupEmail,
|
||
display_name: setupDisplayName,
|
||
password: setupPassword,
|
||
}),
|
||
});
|
||
setSetupRequired(false);
|
||
await load();
|
||
} catch (e) {
|
||
setSetupError(e?.message || "Setup failed.");
|
||
} finally {
|
||
setSetupSaving(false);
|
||
}
|
||
};
|
||
|
||
const doLogout = async () => {
|
||
await api("/auth/logout", { method: "POST" });
|
||
setMe(null);
|
||
setGames([]);
|
||
setGameId(null);
|
||
setSheet(null);
|
||
setGameMeta(null);
|
||
setMembers([]);
|
||
setGameChips([]);
|
||
setWinnerUserId("");
|
||
|
||
// reset winner celebration on logout
|
||
winnerBaselineRef.current = false;
|
||
lastWinnerIdRef.current = null;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
};
|
||
|
||
// ===== Password =====
|
||
const openPwModal = () => {
|
||
setPwMsg("");
|
||
setPw1("");
|
||
setPw2("");
|
||
setPwOpen(true);
|
||
setUserMenuOpen(false);
|
||
};
|
||
|
||
const closePwModal = () => {
|
||
setPwOpen(false);
|
||
setPwMsg("");
|
||
setPw1("");
|
||
setPw2("");
|
||
};
|
||
|
||
const savePassword = async () => {
|
||
setPwMsg("");
|
||
|
||
if (!pw1 || pw1.length < 8) return setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
|
||
if (pw1 !== pw2) return setPwMsg("❌ Passwörter stimmen nicht überein.");
|
||
|
||
setPwSaving(true);
|
||
try {
|
||
await api("/auth/password", {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ password: pw1 }),
|
||
});
|
||
setPwMsg("✅ Passwort gespeichert.");
|
||
setTimeout(() => closePwModal(), 650);
|
||
} catch (e) {
|
||
setPwMsg("❌ Fehler: " + (e?.message || "unknown"));
|
||
} finally {
|
||
setPwSaving(false);
|
||
}
|
||
};
|
||
|
||
// ===== Theme =====
|
||
const openDesignModal = () => {
|
||
setDesignOpen(true);
|
||
setUserMenuOpen(false);
|
||
};
|
||
|
||
const selectTheme = async (key) => {
|
||
setThemeKey(key);
|
||
applyTheme(key);
|
||
|
||
// ✅ sofort für nächsten Start merken (verhindert Flash)
|
||
try {
|
||
localStorage.setItem(`hpTheme:${(me?.email || "guest").toLowerCase()}`, key);
|
||
localStorage.setItem("hpTheme:guest", key); // fallback, falls noch nicht eingeloggt
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
try {
|
||
await api("/auth/theme", {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ theme_key: key }),
|
||
});
|
||
} catch {
|
||
// theme locally already applied; ignore backend error
|
||
}
|
||
};
|
||
|
||
|
||
// ===== Stats (always fresh on open) =====
|
||
const openStatsModal = async () => {
|
||
setUserMenuOpen(false);
|
||
setStatsOpen(true);
|
||
setStatsError("");
|
||
setStatsLoading(true);
|
||
|
||
try {
|
||
const s = await api("/auth/me/stats");
|
||
setStats(s);
|
||
} catch (e) {
|
||
setStats(null);
|
||
setStatsError("❌ Fehler: " + (e?.message || "unknown"));
|
||
} finally {
|
||
setStatsLoading(false);
|
||
}
|
||
};
|
||
|
||
const closeStatsModal = () => {
|
||
setStatsOpen(false);
|
||
setStatsError("");
|
||
};
|
||
|
||
// ===== New game flow =====
|
||
const createGame = async () => {
|
||
// ✅ alten Game-State komplett loswerden, damit nix am alten Spiel "hängen bleibt"
|
||
setSheet(null);
|
||
setGameMeta(null);
|
||
setMembers([]);
|
||
setGameChips([]);
|
||
setWinnerUserId("");
|
||
setPulseId(null);
|
||
|
||
// auch Chip-Modal-State resetten
|
||
setChipOpen(false);
|
||
setChipEntry(null);
|
||
|
||
// reset winner celebration baseline for the new game
|
||
winnerBaselineRef.current = false;
|
||
lastWinnerIdRef.current = null;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
|
||
const g = await api("/games", {
|
||
method: "POST",
|
||
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
||
});
|
||
|
||
const gs = await api("/games");
|
||
setGames(gs);
|
||
|
||
// ✅ auf neues Game wechseln (triggert reloadSheet/loadGameMeta via effect)
|
||
setGameId(g.id);
|
||
|
||
return g; // includes code
|
||
};
|
||
|
||
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.id);
|
||
};
|
||
|
||
const startGame = async () => {
|
||
if (!gameId || !gameMeta?.host_user_id || me?.id !== gameMeta.host_user_id) return;
|
||
await api(`/games/${gameId}/start`, { method: "POST" });
|
||
await loadGameMeta();
|
||
};
|
||
|
||
// ===== Winner =====
|
||
const saveWinner = async () => {
|
||
if (!gameId) return;
|
||
await api(`/games/${gameId}/winner`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
||
});
|
||
await loadGameMeta();
|
||
};
|
||
|
||
// ===== Sheet actions =====
|
||
const cycleStatus = async (entry) => {
|
||
let next = 0;
|
||
if (entry.status === 0) next = 2;
|
||
else if (entry.status === 2) next = 1;
|
||
else if (entry.status === 1) next = 3;
|
||
else next = 0;
|
||
|
||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ status: next }),
|
||
});
|
||
|
||
await reloadSheet();
|
||
setPulseId(entry.entry_id);
|
||
setTimeout(() => setPulseId(null), 220);
|
||
};
|
||
|
||
const toggleTag = async (entry) => {
|
||
const next = cycleTag(entry.note_tag);
|
||
|
||
if (next === "s") {
|
||
if (!gameMeta?.started || gameChips.length === 0) {
|
||
showSnack("Das Spiel muss zuerst gestartet werden.");
|
||
return;
|
||
}
|
||
setChipEntry(entry);
|
||
setChipOpen(true);
|
||
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, chip: null }),
|
||
});
|
||
|
||
await reloadSheet();
|
||
};
|
||
|
||
const chooseChip = async (chip) => {
|
||
if (!chipEntry) return;
|
||
|
||
const entry = chipEntry;
|
||
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 }),
|
||
});
|
||
} finally {
|
||
await reloadSheet();
|
||
}
|
||
};
|
||
|
||
const closeChipModalToDash = async () => {
|
||
if (!chipEntry) {
|
||
setChipOpen(false);
|
||
return;
|
||
}
|
||
|
||
const entry = chipEntry;
|
||
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, chip: null }),
|
||
});
|
||
} finally {
|
||
await reloadSheet();
|
||
}
|
||
};
|
||
|
||
const displayTag = (entry) => {
|
||
const t = entry.note_tag;
|
||
if (!t) return "—";
|
||
|
||
if (t === "s") {
|
||
// Prefer backend chip, fallback localStorage
|
||
const chip = entry.chip || getChipLS(gameId, entry.entry_id);
|
||
return chip ? `s.${chip}` : "s";
|
||
}
|
||
|
||
return t; // i oder m
|
||
};
|
||
|
||
// ===== Login page =====
|
||
if (!me) {
|
||
return (
|
||
<LoginPage
|
||
loginEmail={loginEmail}
|
||
setLoginEmail={setLoginEmail}
|
||
loginPassword={loginPassword}
|
||
setLoginPassword={setLoginPassword}
|
||
showPw={showPw}
|
||
setShowPw={setShowPw}
|
||
doLogin={doLogin}
|
||
setupRequired={setupRequired}
|
||
setupEmail={setupEmail}
|
||
setSetupEmail={setSetupEmail}
|
||
setupDisplayName={setupDisplayName}
|
||
setSetupDisplayName={setSetupDisplayName}
|
||
setupPassword={setupPassword}
|
||
setSetupPassword={setSetupPassword}
|
||
setupPasswordConfirm={setupPasswordConfirm}
|
||
setSetupPasswordConfirm={setSetupPasswordConfirm}
|
||
setupError={setupError}
|
||
setupSaving={setupSaving}
|
||
doSetup={doSetup}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const sections = sheet
|
||
? [
|
||
{ key: "suspect", title: "VERDÄCHTIGE PERSON", entries: sheet.suspect || [] },
|
||
{ key: "item", title: "GEGENSTAND", entries: sheet.item || [] },
|
||
{ key: "location", title: "ORT", entries: sheet.location || [] },
|
||
]
|
||
: [];
|
||
|
||
const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id);
|
||
const gameStarted = !!gameMeta?.started;
|
||
|
||
return (
|
||
<div style={styles.page}>
|
||
{/* Winner Celebration Overlay */}
|
||
<WinnerCelebration
|
||
open={celebrateOpen}
|
||
winnerName={celebrateName}
|
||
onClose={() => setCelebrateOpen(false)}
|
||
/>
|
||
|
||
<div style={styles.bgFixed} aria-hidden="true">
|
||
<div style={styles.bgMap} />
|
||
</div>
|
||
|
||
<div className="hp-shell" style={styles.shell}>
|
||
<TopBar
|
||
me={me}
|
||
userMenuOpen={userMenuOpen}
|
||
setUserMenuOpen={setUserMenuOpen}
|
||
openPwModal={openPwModal}
|
||
openDesignModal={openDesignModal}
|
||
openStatsModal={openStatsModal}
|
||
doLogout={doLogout}
|
||
onOpenNewGame={() => setNewGameOpen(true)}
|
||
/>
|
||
|
||
{me.role === "admin" && <AdminPanel />}
|
||
|
||
<GamePickerCard
|
||
games={games}
|
||
gameId={gameId}
|
||
setGameId={setGameId}
|
||
onOpenHelp={() => setHelpOpen(true)}
|
||
members={members}
|
||
me={me}
|
||
hostUserId={gameMeta?.host_user_id || ""}
|
||
isHost={isHost}
|
||
started={!!gameMeta?.started}
|
||
finished={!!gameMeta?.winner_user_id}
|
||
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
|
||
chipCount={gameChips.length}
|
||
onStartGame={startGame}
|
||
/>
|
||
|
||
{gameStarted && (
|
||
<WinnerBadge
|
||
winner={{
|
||
display_name: gameMeta?.winner_display_name || "",
|
||
email: gameMeta?.winner_email || "",
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||
|
||
{gameStarted && (
|
||
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
|
||
{sections.map((sec) => (
|
||
<SheetSection
|
||
key={sec.key}
|
||
title={sec.title}
|
||
entries={sec.entries}
|
||
pulseId={pulseId}
|
||
onCycleStatus={cycleStatus}
|
||
onToggleTag={toggleTag}
|
||
displayTag={displayTag}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Host-only Winner Auswahl */}
|
||
{gameStarted && (
|
||
<WinnerCard
|
||
isHost={isHost}
|
||
members={members}
|
||
winnerUserId={winnerUserId}
|
||
setWinnerUserId={setWinnerUserId}
|
||
onSave={saveWinner}
|
||
/>
|
||
)}
|
||
|
||
<div style={{ height: 24 }} />
|
||
</div>
|
||
|
||
<PasswordModal
|
||
pwOpen={pwOpen}
|
||
closePwModal={closePwModal}
|
||
pw1={pw1}
|
||
setPw1={setPw1}
|
||
pw2={pw2}
|
||
setPw2={setPw2}
|
||
pwMsg={pwMsg}
|
||
pwSaving={pwSaving}
|
||
savePassword={savePassword}
|
||
/>
|
||
|
||
<DesignModal
|
||
open={designOpen}
|
||
onClose={() => setDesignOpen(false)}
|
||
themeKey={themeKey}
|
||
onSelect={(k) => {
|
||
selectTheme(k);
|
||
setDesignOpen(false);
|
||
}}
|
||
/>
|
||
|
||
<NewGameModal
|
||
open={newGameOpen}
|
||
onClose={() => setNewGameOpen(false)}
|
||
onCreate={createGame}
|
||
onJoin={joinGame}
|
||
currentCode={gameMeta?.code || ""}
|
||
gameFinished={!!gameMeta?.winner_user_id}
|
||
hasGame={!!gameId}
|
||
currentMembers={members}
|
||
/>
|
||
|
||
<ChipModal
|
||
chipOpen={chipOpen}
|
||
closeChipModalToDash={closeChipModalToDash}
|
||
chooseChip={chooseChip}
|
||
chips={gameChips}
|
||
/>
|
||
|
||
<StatsModal
|
||
open={statsOpen}
|
||
onClose={closeStatsModal}
|
||
me={me}
|
||
stats={stats}
|
||
loading={statsLoading}
|
||
error={statsError}
|
||
/>
|
||
|
||
{/* Bottom snack for joins */}
|
||
{snack &&
|
||
createPortal(
|
||
<div
|
||
style={{
|
||
position: "fixed",
|
||
left: "50%",
|
||
bottom: 14,
|
||
transform: "translateX(-50%)",
|
||
maxWidth: "92vw",
|
||
padding: "10px 12px",
|
||
borderRadius: 14,
|
||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||
background: stylesTokens.panelBg,
|
||
color: stylesTokens.textMain,
|
||
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
|
||
backdropFilter: "blur(6px)",
|
||
fontWeight: 900,
|
||
fontSize: 13,
|
||
textAlign: "center",
|
||
zIndex: 2147483647,
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{snack}
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
}
|