Files
cluedo-hp-webapp/frontend/src/App.jsx
T
nessi 29e063d88d Implement dynamic lobby polling with faster refresh rate and prevent concurrent requests
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.
2026-08-02 09:22:48 +02:00

796 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}