Reorganized help sections to match game flow: lobby/start, game selection, status marking, notes/chips, game end, and user menu. Updated section titles and descriptions for clarity, added lobby mechanics explanation with host controls and chip generation, and included game completion details with read-only enforcement. Removed source file comment, simplified badge styling with inline styles, and refined text for better readability. Moved privacy tip to end and adjusted section numb
843 lines
23 KiB
React
843 lines
23 KiB
React
import React, { useEffect, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import WinnerCelebration from "./components/WinnerCelebration";
|
||
import GameStartCelebration from "./components/GameStartCelebration";
|
||
|
||
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 [adminOpen, setAdminOpen] = 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("");
|
||
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
|
||
|
||
// baseline per game: beim ersten Meta-Load NICHT feiern
|
||
const winnerBaselineRef = useRef(false);
|
||
const lastWinnerIdRef = useRef(null);
|
||
const gameStartBaselineRef = useRef(false);
|
||
const lastGameStartedRef = useRef(false);
|
||
|
||
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;
|
||
gameStartBaselineRef.current = false;
|
||
lastGameStartedRef.current = false;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
setStartCelebrateOpen(false);
|
||
|
||
(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]);
|
||
|
||
useEffect(() => {
|
||
if (!gameMeta) return;
|
||
|
||
const started = !!gameMeta.started;
|
||
if (!gameStartBaselineRef.current) {
|
||
gameStartBaselineRef.current = true;
|
||
lastGameStartedRef.current = started;
|
||
return;
|
||
}
|
||
|
||
if (!lastGameStartedRef.current && started) {
|
||
setStartCelebrateOpen(true);
|
||
vibrate([25, 55, 25]);
|
||
}
|
||
lastGameStartedRef.current = started;
|
||
}, [gameMeta?.started]);
|
||
|
||
|
||
// ===== 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;
|
||
gameStartBaselineRef.current = false;
|
||
lastGameStartedRef.current = false;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
setStartCelebrateOpen(false);
|
||
};
|
||
|
||
// ===== 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;
|
||
gameStartBaselineRef.current = false;
|
||
lastGameStartedRef.current = false;
|
||
setCelebrateOpen(false);
|
||
setCelebrateName("");
|
||
setStartCelebrateOpen(false);
|
||
|
||
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) => {
|
||
if (gameMeta?.winner_user_id) return;
|
||
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();
|
||
vibrate(10);
|
||
setPulseId(entry.entry_id);
|
||
setTimeout(() => setPulseId(null), 220);
|
||
};
|
||
|
||
const toggleTag = async (entry) => {
|
||
if (gameMeta?.winner_user_id) return;
|
||
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();
|
||
vibrate(10);
|
||
};
|
||
|
||
const chooseChip = async (chip) => {
|
||
if (!chipEntry || gameMeta?.winner_user_id) 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();
|
||
vibrate(12);
|
||
}
|
||
};
|
||
|
||
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)}
|
||
/>
|
||
<GameStartCelebration
|
||
open={startCelebrateOpen}
|
||
onClose={() => setStartCelebrateOpen(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}
|
||
openAdminPanel={() => {
|
||
setAdminOpen(true);
|
||
setUserMenuOpen(false);
|
||
}}
|
||
doLogout={doLogout}
|
||
onOpenNewGame={() => setNewGameOpen(true)}
|
||
/>
|
||
|
||
{me.role === "admin" && (
|
||
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} />
|
||
)}
|
||
|
||
<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}
|
||
readOnly={!!gameMeta?.winner_user_id}
|
||
/>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|