new logic, testing

This commit is contained in:
2026-02-07 11:15:33 +01:00
parent 97ad77f2a4
commit cf81c25e6e

View File

@@ -1,6 +1,4 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import WinnerCelebration from "./components/WinnerCelebration";
import { api } from "./api/client"; import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag"; import { cycleTag } from "./utils/cycleTag";
@@ -11,19 +9,9 @@ import { styles } from "./styles/styles";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { stylesTokens } from "./styles/theme"; import { stylesTokens } from "./styles/theme";
import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage"; 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 SheetSection from "./components/SheetSection";
import DesignModal from "./components/DesignModal"; import ChipModal from "./components/ChipModal";
import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
@@ -34,87 +22,30 @@ export default function App() {
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
// Game/Sheet state // Game/Sheet state (minimal)
const [games, setGames] = useState([]); const [games, setGames] = useState([]);
const [gameId, setGameId] = useState(null); const [gameId, setGameId] = useState(null);
const [sheet, setSheet] = useState(null); const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null); const [pulseId, setPulseId] = useState(null);
// Game meta // Chip modal
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
const [members, setMembers] = useState([]);
// Winner selection (host only)
const [winnerUserId, setWinnerUserId] = useState("");
// Modals
const [helpOpen, setHelpOpen] = useState(false);
const [chipOpen, setChipOpen] = useState(false); const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null); const [chipEntry, setChipEntry] = useState(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false); // Live refresh (optional)
const [pw1, setPw1] = useState(""); const aliveRef = useRef(true);
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 load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
const tk = m?.theme_key || DEFAULT_THEME_KEY; const tk = m?.theme_key || DEFAULT_THEME_KEY;
setThemeKey(tk);
applyTheme(tk); applyTheme(tk);
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
// Auto-pick first game (kein UI dafür)
if (gs[0] && !gameId) setGameId(gs[0].id); if (gs[0] && !gameId) setGameId(gs[0].id);
}; };
@@ -124,110 +55,46 @@ export default function App() {
setSheet(sh); setSheet(sh);
}; };
const loadGameMeta = async () => {
if (!gameId) return;
const meta = await api(`/games/${gameId}`);
setGameMeta(meta);
setWinnerUserId(meta?.winner_user_id || "");
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 // initial load
useEffect(() => { useEffect(() => {
aliveRef.current = true;
(async () => { (async () => {
try { try {
await load(); await load();
} catch {} } catch {
// ignore
}
})(); })();
return () => {
aliveRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// on game change // on game change
useEffect(() => { 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 () => { (async () => {
if (!gameId) return; if (!gameId) return;
try { try {
await reloadSheet(); await reloadSheet();
await loadGameMeta(); } catch {
} catch {} // ignore
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
// Live refresh (Members/Meta) damit neue Joiner ohne Reload sichtbar sind // Live refresh (nur Sheet)
// Für 56 Spieler reicht 2.5s völlig, ist "live genug" und schont Backend.
useEffect(() => { useEffect(() => {
if (!me || !gameId) return; if (!me || !gameId) return;
let alive = true; let alive = true;
const tick = async () => { const tick = async () => {
try { try {
await loadGameMeta(); // refresh members + winner meta await reloadSheet();
} catch { } catch {}
// ignore
}
}; };
// sofort einmal ziehen
tick(); tick();
const id = setInterval(() => { const id = setInterval(() => {
if (!alive) return; if (!alive) return;
tick(); tick();
@@ -237,38 +104,8 @@ export default function App() {
alive = false; alive = false;
clearInterval(id); clearInterval(id);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id, gameId]); }, [me?.id, gameId]);
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 ===== // ===== Auth actions =====
const doLogin = async () => { const doLogin = async () => {
await api("/auth/login", { await api("/auth/login", {
@@ -278,167 +115,7 @@ export default function App() {
await load(); await load();
}; };
const doLogout = async () => { // ===== Sheet actions (wie bisher) =====
await api("/auth/logout", { method: "POST" });
setMe(null);
setGames([]);
setGameId(null);
setSheet(null);
setGameMeta(null);
setMembers([]);
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([]);
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);
};
// ===== 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) => { const cycleStatus = async (entry) => {
let next = 0; let next = 0;
if (entry.status === 0) next = 2; if (entry.status === 0) next = 2;
@@ -521,11 +198,9 @@ export default function App() {
if (!t) return "—"; if (!t) return "—";
if (t === "s") { if (t === "s") {
// Prefer backend chip, fallback localStorage
const chip = entry.chip || getChipLS(gameId, entry.entry_id); const chip = entry.chip || getChipLS(gameId, entry.entry_id);
return chip ? `s.${chip}` : "s"; return chip ? `s.${chip}` : "s";
} }
return t; // i oder m return t; // i oder m
}; };
@@ -552,56 +227,147 @@ export default function App() {
] ]
: []; : [];
const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id); const PlaceholderCard = ({ title, hint }) => (
<div
style={{
borderRadius: 18,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(8px)",
padding: 12,
overflow: "hidden",
}}
>
<div style={{ fontWeight: 900, color: stylesTokens.textMain, fontSize: 13 }}>
{title}
</div>
<div style={{ marginTop: 6, color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>
{hint}
</div>
<div
style={{
marginTop: 10,
height: 64,
borderRadius: 14,
border: `1px dashed ${stylesTokens.panelBorder}`,
opacity: 0.8,
}}
/>
</div>
);
return ( return (
<div style={styles.page}> <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.bgFixed} aria-hidden="true">
<div style={styles.bgMap} /> <div style={styles.bgMap} />
</div> </div>
<div style={styles.shell}> {/* Layout: Links Game/Board, Rechts Notizen */}
<TopBar <div
me={me} style={{
userMenuOpen={userMenuOpen} ...styles.shell,
setUserMenuOpen={setUserMenuOpen} display: "grid",
openPwModal={openPwModal} gridTemplateColumns: "1fr min(420px, 36vw)",
openDesignModal={openDesignModal} gap: 14,
openStatsModal={openStatsModal} alignItems: "start",
doLogout={doLogout} }}
onOpenNewGame={() => setNewGameOpen(true)} >
/> {/* LEFT: Game Area (Placeholders) */}
<div style={{ display: "grid", gap: 14 }}>
{/* Top bar placeholders (User + Settings) */}
<div style={{ display: "flex", gap: 10, justifyContent: "space-between" }}>
<PlaceholderCard title="User Dropdown" hint="(placeholder)" />
<PlaceholderCard title="Einstellungen" hint="(placeholder)" />
</div>
{me.role === "admin" && <AdminPanel />} {/* Center Board */}
<div
<GamePickerCard style={{
games={games} borderRadius: 22,
gameId={gameId} border: `1px solid ${stylesTokens.panelBorder}`,
setGameId={setGameId} background: stylesTokens.panelBg,
onOpenHelp={() => setHelpOpen(true)} boxShadow: "0 20px 70px rgba(0,0,0,0.45)",
members={members} backdropFilter: "blur(10px)",
me={me} padding: 12,
hostUserId={gameMeta?.host_user_id || ""} position: "relative",
/> overflow: "hidden",
minHeight: 420,
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} }}
<WinnerBadge >
winner={{ <div
display_name: gameMeta?.winner_display_name || "", style={{
email: gameMeta?.winner_email || "", position: "absolute",
inset: 0,
background: `linear-gradient(90deg, transparent, ${stylesTokens.goldLine}, transparent)`,
opacity: 0.25,
pointerEvents: "none",
}} }}
/> />
<div style={{ position: "relative" }}>
<div style={{ fontWeight: 900, color: stylesTokens.textMain, fontSize: 14 }}>
3D Board / Game View
</div>
<div style={{ marginTop: 6, color: stylesTokens.textDim, fontSize: 12 }}>
Platzhalter hier kommt später das Board + Figuren rein.
</div>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} /> <div
style={{
marginTop: 10,
height: 360,
borderRadius: 18,
border: `1px dashed ${stylesTokens.panelBorder}`,
opacity: 0.85,
}}
/>
</div>
</div>
<div style={{ marginTop: 14, display: "grid", gap: 14 }}> {/* Bottom row placeholders */}
<div
style={{
display: "grid",
gridTemplateColumns: "1.1fr 0.8fr 1.1fr",
gap: 14,
}}
>
<PlaceholderCard title="Meine Geheimkarten" hint="(placeholder)" />
<PlaceholderCard title="Würfel + Hogwarts Points" hint="(placeholder)" />
<PlaceholderCard title="Spielerkarte / Turn" hint="(placeholder)" />
</div>
{/* Extra: Hilfskarten / Deck */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
<PlaceholderCard title="Dunkles Deck" hint="(placeholder)" />
<PlaceholderCard title="Hilfskarten" hint="(placeholder)" />
</div>
</div>
{/* RIGHT: Notes Panel */}
<div
style={{
position: "sticky",
top: 12,
alignSelf: "start",
borderRadius: 22,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 22px 90px rgba(0,0,0,0.55)",
backdropFilter: "blur(10px)",
padding: 12,
maxHeight: "calc(100dvh - 24px)",
overflow: "auto",
}}
>
<div style={{ fontWeight: 900, color: stylesTokens.textMain, fontSize: 14 }}>
Notizen
</div>
<div style={{ marginTop: 6, color: stylesTokens.textDim, fontSize: 12 }}>
Nur die 3 Tabellen (Verdächtige / Gegenstände / Orte).
</div>
<div style={{ marginTop: 12, display: "grid", gap: 14 }}>
{sections.map((sec) => ( {sections.map((sec) => (
<SheetSection <SheetSection
key={sec.key} key={sec.key}
@@ -614,95 +380,14 @@ export default function App() {
/> />
))} ))}
</div> </div>
{/* Host-only Winner Auswahl */}
<WinnerCard
isHost={isHost}
members={members}
winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId}
onSave={saveWinner}
/>
<div style={{ height: 24 }} />
</div> </div>
</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 <ChipModal
chipOpen={chipOpen} chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash} closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip} chooseChip={chooseChip}
/> />
<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> </div>
); );
} }