diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5b9d8d9..07d7217 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,190 +1,21 @@
import React, { useEffect, useState } from "react";
+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 { stylesTokens } from "./styles/theme";
-const API = "/api";
-const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
+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";
+// HelpModal + SheetSection würdest du analog auslagern
-async function api(path, opts = {}) {
- const res = await fetch(API + path, {
- credentials: "include",
- headers: { "Content-Type": "application/json" },
- ...opts,
- });
- if (!res.ok) throw new Error(await res.text());
- return res.json();
-}
-
-/**
- * Backend erlaubt: null | "i" | "m" | "s"
- * Rotation:
- * null -> i -> m -> s (Popup) -> null
- */
-function cycleTag(tag) {
- if (!tag) return "i";
- if (tag === "i") return "m";
- if (tag === "m") return "s";
- return null; // "s" -> null
-}
-
-/* ========= Chip localStorage (Frontend-only) ========= */
-function chipStorageKey(gameId, entryId) {
- return `chip:${gameId}:${entryId}`;
-}
-
-function getChipLS(gameId, entryId) {
- try {
- return localStorage.getItem(chipStorageKey(gameId, entryId));
- } catch {
- return null;
- }
-}
-
-function setChipLS(gameId, entryId, chip) {
- try {
- localStorage.setItem(chipStorageKey(gameId, entryId), chip);
- } catch {}
-}
-
-function clearChipLS(gameId, entryId) {
- try {
- localStorage.removeItem(chipStorageKey(gameId, entryId));
- } catch {}
-}
-
-/* ========= Admin Panel ========= */
-function AdminPanel() {
- const [users, setUsers] = useState([]);
-
- const [open, setOpen] = useState(false);
- const [email, setEmail] = useState("");
- const [password, setPassword] = useState("");
- const [role, setRole] = useState("user");
- const [msg, setMsg] = useState("");
-
- const loadUsers = async () => {
- const u = await api("/admin/users");
- setUsers(u);
- };
-
- useEffect(() => {
- loadUsers().catch(() => {});
- }, []);
-
- const resetForm = () => {
- setEmail("");
- setPassword("");
- setRole("user");
- };
-
- const createUser = async () => {
- setMsg("");
- try {
- await api("/admin/users", {
- method: "POST",
- body: JSON.stringify({ email, password, role }),
- });
- setMsg("✅ User erstellt.");
- await loadUsers();
- resetForm();
- setOpen(false);
- } catch (e) {
- setMsg("❌ Fehler: " + (e?.message || "unknown"));
- }
- };
-
- const closeModal = () => {
- setOpen(false);
- setMsg("");
- };
-
- return (
-
-
-
Admin Dashboard
-
-
-
-
- Vorhandene User
-
-
-
- {users.map((u) => (
-
-
{u.email}
-
- {u.role}
-
-
- {u.disabled ? "disabled" : "active"}
-
-
- ))}
-
-
- {open && (
-
-
e.stopPropagation()}>
-
-
- Neuen User anlegen
-
-
-
-
-
-
setEmail(e.target.value)}
- placeholder="Email"
- style={styles.input}
- autoFocus
- />
-
setPassword(e.target.value)}
- placeholder="Initial Passwort"
- type="password"
- style={styles.input}
- />
-
-
- {msg &&
{msg}
}
-
-
-
-
-
-
-
- Tipp: Klick auf Item: Grün → Rot → Grau → Leer
-
-
-
-
- )}
-
- );
-}
-
-/* ========= App ========= */
export default function App() {
+ useHpGlobalStyles();
+
const [me, setMe] = useState(null);
const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState("");
@@ -195,13 +26,11 @@ export default function App() {
const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null);
- // Chip popup
const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
- // User dropdown + Passwort Modal
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
@@ -220,105 +49,21 @@ export default function App() {
if (gs[0] && !gameId) setGameId(gs[0].id);
};
- // Usermanager
+ // Dropdown outside click
useEffect(() => {
const onDown = (e) => {
- // wenn Dropdown offen & Klick ist NICHT in einem Element mit data-user-menu
const root = e.target?.closest?.("[data-user-menu]");
if (!root) setUserMenuOpen(false);
};
-
if (userMenuOpen) document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [userMenuOpen]);
- // Google Fonts
- useEffect(() => {
- if (document.getElementById("hp-fonts")) return;
-
- const pre1 = document.createElement("link");
- pre1.id = "hp-fonts-pre1";
- pre1.rel = "preconnect";
- pre1.href = "https://fonts.googleapis.com";
-
- const pre2 = document.createElement("link");
- pre2.id = "hp-fonts-pre2";
- pre2.rel = "preconnect";
- pre2.href = "https://fonts.gstatic.com";
- pre2.crossOrigin = "anonymous";
-
- const link = document.createElement("link");
- link.id = "hp-fonts";
- link.rel = "stylesheet";
- link.href =
- "https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&family=IM+Fell+English:ital,wght@0,400;0,700;1,400&display=swap";
-
- document.head.appendChild(pre1);
- document.head.appendChild(pre2);
- document.head.appendChild(link);
- }, []);
-
- // Keyframes
- useEffect(() => {
- if (document.getElementById("hp-anim-style")) return;
- const style = document.createElement("style");
- style.id = "hp-anim-style";
- style.innerHTML = `
- @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
- @keyframes popIn { from { opacity: 0; transform: translateY(8px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
- @keyframes rowPulse { 0%{ transform: scale(1); } 50%{ transform: scale(1.01); } 100%{ transform: scale(1); } }
- @keyframes candleGlow {
- 0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
- 35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
- 70% { opacity: .62; transform: translateY(1px) scale(1.01); filter: blur(17px); }
- 100% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
- }
- `;
- document.head.appendChild(style);
- }, []);
-
- // html/body reset
- useEffect(() => {
- document.documentElement.style.height = "100%";
- document.body.style.height = "100%";
- document.documentElement.style.margin = "0";
- document.body.style.margin = "0";
- document.documentElement.style.padding = "0";
- document.body.style.padding = "0";
- }, []);
-
- // Global CSS
- useEffect(() => {
- if (document.getElementById("hp-global-style")) return;
- const style = document.createElement("style");
- style.id = "hp-global-style";
- style.innerHTML = `
- html, body {
- overscroll-behavior-y: none;
- -webkit-text-size-adjust: 100%;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- background: ${stylesTokens.pageBg};
- color: ${stylesTokens.textMain};
- }
- body {
- overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
- }
- #root { background: transparent; }
- * { -webkit-tap-highlight-color: transparent; }
- `;
- document.head.appendChild(style);
- }, []);
-
useEffect(() => {
(async () => {
try {
await load();
- } catch {
- // not logged in
- }
+ } catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -329,9 +74,7 @@ export default function App() {
try {
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
- } catch {
- // ignore
- }
+ } catch {}
})();
}, [gameId]);
@@ -351,7 +94,7 @@ export default function App() {
setSheet(null);
};
- // ===== Password change =====
+ // Password change
const openPwModal = () => {
setPwMsg("");
setPw1("");
@@ -369,15 +112,8 @@ export default function App() {
const savePassword = async () => {
setPwMsg("");
-
- if (!pw1 || pw1.length < 8) {
- setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
- return;
- }
- if (pw1 !== pw2) {
- setPwMsg("❌ Passwörter stimmen nicht überein.");
- return;
- }
+ 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 {
@@ -385,12 +121,9 @@ export default function App() {
method: "PATCH",
body: JSON.stringify({ password: pw1 }),
});
-
setPwMsg("✅ Passwort gespeichert.");
- // Optional: nach kurzer Zeit automatisch schließen
setTimeout(() => closePwModal(), 650);
} catch (e) {
- // dein api() wirft res.text() -> oft JSON Detail. Wir zeigen es einfach roh.
setPwMsg("❌ Fehler: " + (e?.message || "unknown"));
} finally {
setPwSaving(false);
@@ -430,21 +163,16 @@ export default function App() {
setTimeout(() => setPulseId(null), 220);
};
- // Notiz-Button: i -> m -> (Popup) s -> null
const toggleTag = async (entry) => {
const next = cycleTag(entry.note_tag);
- // Wenn wir zu "s" gehen würden -> Chip Popup öffnen, aber NICHT ins Backend schreiben
if (next === "s") {
setChipEntry(entry);
setChipOpen(true);
return;
}
- // Wenn wir auf null gehen, Chip lokal löschen (weil s -> —)
- if (next === null) {
- clearChipLS(gameId, entry.entry_id);
- }
+ if (next === null) clearChipLS(gameId, entry.entry_id);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
@@ -453,22 +181,16 @@ export default function App() {
await reloadSheet();
};
- // Chip wählen:
- // Backend: note_tag = "s"
- // Frontend: Chip in localStorage
const chooseChip = async (chip) => {
if (!chipEntry) return;
- // UI sofort schließen -> fühlt sich besser an
const entry = chipEntry;
setChipOpen(false);
setChipEntry(null);
- // local speichern
setChipLS(gameId, entry.entry_id, chip);
try {
- // Backend bekommt nur "s"
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: "s" }),
@@ -478,24 +200,19 @@ export default function App() {
}
};
- // X im Modal:
- // Backend zurück auf null und lokalen Chip löschen
const closeChipModalToDash = async () => {
if (!chipEntry) {
setChipOpen(false);
return;
}
- // UI sofort schließen
const entry = chipEntry;
setChipOpen(false);
setChipEntry(null);
- // Frontend-only Chip entfernen
clearChipLS(gameId, entry.entry_id);
try {
- // Backend zurück auf —
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: null }),
@@ -505,115 +222,31 @@ export default function App() {
}
};
- // Anzeige im Tag-Button:
- // - "s" wird zu "s.AL" (aus localStorage), sonst "s"
const displayTag = (entry) => {
const t = entry.note_tag;
if (!t) return "—";
-
if (t === "s") {
const chip = getChipLS(gameId, entry.entry_id);
- return chip ? `s.${chip}` : "s"; // <-- genau wie gewünscht
+ return chip ? `s.${chip}` : "s";
}
-
- return t; // i oder m
+ return t;
};
- // --- helpers ---
- const getRowBg = (status) => {
- if (status === 1) return "rgba(255, 35, 35, 0.16)";
- if (status === 2) return "rgba(0, 190, 80, 0.16)";
- if (status === 3) return "rgba(140, 140, 140, 0.12)";
- return "rgba(255,255,255,0.06)";
- };
-
- const getNameColor = (status) => {
- if (status === 1) return "#ffb3b3";
- if (status === 2) return "#baf3c9";
- if (status === 3) return "rgba(233,216,166,0.78)";
- return stylesTokens.textMain;
- };
-
- const getStatusSymbol = (status) => {
- if (status === 2) return "✓";
- if (status === 1) return "✕";
- if (status === 3) return "?";
- return "–";
- };
-
- const getStatusBadge = (status) => {
- if (status === 2) return { color: "#baf3c9", background: "rgba(0,190,80,0.18)" };
- if (status === 1) return { color: "#ffb3b3", background: "rgba(255,35,35,0.18)" };
- if (status === 3)
- return { color: "rgba(233,216,166,0.85)", background: "rgba(140,140,140,0.14)" };
- return { color: "rgba(233,216,166,0.75)", background: "rgba(255,255,255,0.08)" };
- };
-
- const closeHelp = () => setHelpOpen(false);
-
- // ===== Login =====
+ // ===== Login page =====
if (!me) {
return (
-
-
-
-
-
-
-
Zauber-Detektiv Notizbogen
-
-
Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen
-
-
-
- setLoginEmail(e.target.value)}
- placeholder="Email"
- style={styles.loginInput}
- inputMode="email"
- autoComplete="username"
- />
-
-
-
-
- setLoginPassword(e.target.value)}
- placeholder="Passwort"
- type={showPw ? "text" : "password"}
- style={styles.inputInRow}
- autoComplete="current-password"
- />
-
-
-
-
-
-
-
-
- Deine Notizen bleiben privat – jeder Spieler sieht nur seinen eigenen Zettel.
-
-
-
+
);
}
- // ===== Main =====
const sections = sheet
? [
{ key: "suspect", title: "VERDÄCHTIGE PERSON", entries: sheet.suspect || [] },
@@ -629,825 +262,34 @@ export default function App() {
-
-
-
{me.email}
-
{me.role}
-
-
-
-
-
-
- {userMenuOpen && (
-
-
-
-
-
-
-
- )}
-
-
-
-
-
+
{me.role === "admin" &&
}
-
-
-
Spiel
-
-
-
-
-
-
-
-
- {helpOpen && (
-
-
e.stopPropagation()}>
-
-
-
-
1) Namen anklicken (Status)
-
- Tippe auf einen Namen, um den Status zu wechseln. Reihenfolge:
-
-
-
-
-
- ✓
-
-
- Grün = bestätigt / fix richtig
-
-
-
-
- ✕
-
-
- Rot = ausgeschlossen / fix falsch
-
-
-
-
- ?
-
-
- Grau = unsicher / „vielleicht“
-
-
-
-
- –
-
-
- Leer = unknown / noch nicht bewertet
-
-
-
-
-
-
-
2) i / m / s Button (Notiz)
-
- Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert:
-
-
-
-
-
i
-
- i = „Ich habe diese Geheimkarte“
-
-
-
-
m
-
- m = „Geheimkarte aus dem mittleren Deck“
-
-
-
-
s
-
- s = „Ein anderer Spieler hat diese Karte“ (Chip Auswahl)
-
-
-
-
—
-
- — = keine Notiz
-
-
-
-
-
-
-
- Tipp: Jeder Spieler sieht nur seine eigenen Notizen – andere Spieler können nicht in deinen
- Zettel schauen.
-
-
-
-
- )}
-
- {pwOpen && (
-
-
e.stopPropagation()}>
-
-
Passwort setzen
-
-
-
-
-
setPw1(e.target.value)}
- placeholder="Neues Passwort"
- type="password"
- style={styles.input}
- autoFocus
- />
-
setPw2(e.target.value)}
- placeholder="Neues Passwort wiederholen"
- type="password"
- style={styles.input}
- />
-
- {pwMsg &&
{pwMsg}
}
-
-
-
-
-
-
-
- Hinweis: Mindestens 8 Zeichen empfohlen.
-
-
-
-
- )}
-
- {/* Chip Popup */}
- {chipOpen && (
-
-
e.stopPropagation()}>
-
-
Wer hat die Karte?
-
-
-
-
Chip auswählen:
-
-
- {CHIP_LIST.map((c) => (
-
- ))}
-
-
-
- Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von s.XX zurück auf —.
-
-
-
- )}
-
-
- {sections.map((sec) => (
-
-
{sec.title}
-
-
- {sec.entries.map((e) => {
- // UI "rot" wenn note_tag i oder s (Backend s wird als s.XX angezeigt)
- const isIorMorS = e.note_tag === "i" || e.note_tag === "m" || e.note_tag === "s";
- const effectiveStatus = e.status === 0 && isIorMorS ? 1 : e.status;
-
- const badge = getStatusBadge(effectiveStatus);
-
- return (
-
-
cycleStatus(e)}
- style={{
- ...styles.name,
- textDecoration: effectiveStatus === 1 ? "line-through" : "none",
- color: getNameColor(effectiveStatus),
- opacity: effectiveStatus === 1 ? 0.8 : 1,
- }}
- title="Klick: Grün → Rot → Grau → Leer"
- >
- {e.label}
-
-
-
-
- {getStatusSymbol(effectiveStatus)}
-
-
-
-
-
- );
- })}
-
-
- ))}
-
-
-
+ {/* hier bleiben vorerst: Spiel selector + Help + Sections
+ -> kannst du als nächsten Schritt in Komponenten auslagern */}
+
+
+
+
);
}
-
-/* ===== Theme Tokens ===== */
-const stylesTokens = {
- pageBg: "#0b0b0c",
- panelBg: "rgba(20, 20, 22, 0.55)",
- panelBorder: "rgba(233, 216, 166, 0.14)",
-
- textMain: "rgba(245, 239, 220, 0.92)",
- textDim: "rgba(233, 216, 166, 0.70)",
- textGold: "#e9d8a6",
-
- goldLine: "rgba(233, 216, 166, 0.18)",
-};
-
-/* ===== Styles ===== */
-const styles = {
- page: {
- minHeight: "100dvh",
- background: "transparent",
- position: "relative",
- zIndex: 1,
- },
-
- shell: {
- fontFamily: '"IM Fell English", system-ui',
- padding: 16,
- maxWidth: 680,
- margin: "0 auto",
- },
-
- topBar: {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- gap: 10,
- padding: 12,
- borderRadius: 16,
- background: stylesTokens.panelBg,
- border: `1px solid ${stylesTokens.panelBorder}`,
- boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
- backdropFilter: "blur(6px)",
- },
-
- card: {
- borderRadius: 18,
- overflow: "hidden",
- border: `1px solid ${stylesTokens.panelBorder}`,
- background: "rgba(18, 18, 20, 0.50)",
- boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
- },
-
- cardBody: {
- padding: 12,
- display: "flex",
- gap: 10,
- alignItems: "center",
- },
-
- sectionHeader: {
- padding: "11px 14px",
- fontWeight: 1000,
- fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
- letterSpacing: 1.0,
- color: stylesTokens.textGold,
- background: "linear-gradient(180deg, rgba(32,32,36,0.92), rgba(14,14,16,0.92))",
- borderBottom: `1px solid ${stylesTokens.goldLine}`,
- textTransform: "uppercase",
- textShadow: "0 1px 0 rgba(0,0,0,0.6)",
- },
-
- row: {
- display: "grid",
- gridTemplateColumns: "1fr 54px 68px",
- gap: 10,
- padding: "12px 14px",
- alignItems: "center",
- borderBottom: "1px solid rgba(233,216,166,0.08)",
- borderLeft: "4px solid rgba(0,0,0,0)",
- },
-
- name: {
- cursor: "pointer",
- userSelect: "none",
- fontWeight: 800,
- letterSpacing: 0.2,
- color: stylesTokens.textMain,
- },
-
- statusCell: {
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- },
-
- statusBadge: {
- width: 34,
- height: 34,
- display: "inline-flex",
- justifyContent: "center",
- alignItems: "center",
- borderRadius: 999,
- border: `1px solid rgba(233,216,166,0.18)`,
- fontWeight: 1100,
- fontSize: 16,
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
- },
-
- tagBtn: {
- padding: "8px 0",
- fontWeight: 1000,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- cursor: "pointer",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
- },
-
- helpBtn: {
- padding: "10px 12px",
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- fontWeight: 1000,
- cursor: "pointer",
- whiteSpace: "nowrap",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
- },
-
- input: {
- width: "100%",
- padding: 10,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(10,10,12,0.55)",
- color: stylesTokens.textMain,
- outline: "none",
- fontSize: 16,
- },
-
- primaryBtn: {
- padding: "10px 12px",
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.28)`,
- background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
- color: stylesTokens.textGold,
- fontWeight: 1000,
- cursor: "pointer",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
- },
-
- secondaryBtn: {
- padding: "10px 12px",
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.05)",
- color: stylesTokens.textMain,
- fontWeight: 900,
- cursor: "pointer",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
- },
-
- // Admin
- adminWrap: {
- marginTop: 14,
- padding: 12,
- borderRadius: 16,
- border: `1px solid rgba(233,216,166,0.14)`,
- background: "rgba(18, 18, 20, 0.40)",
- boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
- backdropFilter: "blur(6px)",
- },
- adminTop: {
- display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
- gap: 10,
- },
- adminTitle: {
- fontWeight: 1000,
- color: stylesTokens.textGold,
- },
- userRow: {
- display: "grid",
- gridTemplateColumns: "1fr 80px 90px",
- gap: 8,
- padding: 10,
- borderRadius: 12,
- background: "rgba(255,255,255,0.06)",
- border: `1px solid rgba(233,216,166,0.10)`,
- },
-
- // Modal
- modalOverlay: {
- position: "fixed",
- inset: 0,
- background: "rgba(0,0,0,0.65)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: 16,
- zIndex: 9999,
- animation: "fadeIn 160ms ease-out",
- },
- modalCard: {
- width: "100%",
- maxWidth: 560,
- borderRadius: 18,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "linear-gradient(180deg, rgba(20,20,24,0.92), rgba(12,12,14,0.86))",
- boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
- padding: 14,
- backdropFilter: "blur(6px)",
- animation: "popIn 160ms ease-out",
- color: stylesTokens.textMain,
- },
- modalHeader: {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- gap: 10,
- },
- modalCloseBtn: {
- width: 38,
- height: 38,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- fontWeight: 1000,
- cursor: "pointer",
- lineHeight: "38px",
- textAlign: "center",
- },
-
- // Help
- helpBody: {
- marginTop: 10,
- paddingTop: 4,
- maxHeight: "70vh",
- overflow: "auto",
- },
- helpSectionTitle: {
- fontWeight: 1000,
- color: stylesTokens.textGold,
- marginTop: 10,
- marginBottom: 6,
- },
- helpText: {
- color: stylesTokens.textMain,
- opacity: 0.92,
- lineHeight: 1.35,
- },
- helpList: {
- marginTop: 10,
- display: "grid",
- gap: 8,
- },
- helpListRow: {
- display: "grid",
- gridTemplateColumns: "42px 1fr",
- gap: 10,
- alignItems: "center",
- color: stylesTokens.textMain,
- },
- helpBadge: {
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- width: 38,
- height: 38,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- fontWeight: 1100,
- fontSize: 18,
- },
- helpMiniTag: {
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- width: 38,
- height: 38,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- fontWeight: 1100,
- },
- helpDivider: {
- margin: "14px 0",
- height: 1,
- background: "rgba(233,216,166,0.12)",
- },
-
- // Login
- loginPage: {
- minHeight: "100dvh",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- position: "relative",
- overflow: "hidden",
- padding: 20,
- background: "transparent",
- zIndex: 1,
- },
- loginCard: {
- width: "100%",
- maxWidth: 420,
- padding: 26,
- borderRadius: 22,
- position: "relative",
- zIndex: 2,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(18, 18, 20, 0.55)",
- boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
- backdropFilter: "blur(8px)",
- animation: "popIn 240ms ease-out",
- color: stylesTokens.textMain,
- },
- loginTitle: {
- fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
- fontWeight: 1000,
- fontSize: 26,
- color: stylesTokens.textGold,
- textAlign: "center",
- letterSpacing: 0.6,
- },
- loginSubtitle: {
- marginTop: 6,
- textAlign: "center",
- color: stylesTokens.textMain,
- opacity: 0.9,
- fontSize: 15,
- lineHeight: 1.4,
- },
- loginFieldWrap: {
- width: "100%",
- display: "flex",
- justifyContent: "center",
- },
- loginInput: {
- width: "100%",
- padding: 10,
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(10,10,12,0.60)",
- color: stylesTokens.textMain,
- outline: "none",
- fontSize: 16,
- },
- loginBtn: {
- padding: "12px 14px",
- borderRadius: 14,
- border: `1px solid rgba(233,216,166,0.28)`,
- background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
- color: stylesTokens.textGold,
- fontWeight: 1000,
- fontSize: 16,
- cursor: "pointer",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
- },
- loginHint: {
- marginTop: 18,
- fontSize: 13,
- opacity: 0.78,
- textAlign: "center",
- color: stylesTokens.textDim,
- lineHeight: 1.35,
- },
-
- candleGlowLayer: {
- position: "absolute",
- inset: 0,
- pointerEvents: "none",
- background: `
- radial-gradient(circle at 20% 25%, rgba(255, 200, 120, 0.16), rgba(0,0,0,0) 40%),
- radial-gradient(circle at 80% 30%, rgba(255, 210, 140, 0.12), rgba(0,0,0,0) 42%),
- radial-gradient(circle at 55% 75%, rgba(255, 180, 100, 0.08), rgba(0,0,0,0) 45%)
- `,
- animation: "candleGlow 3.8s ease-in-out infinite",
- mixBlendMode: "multiply",
- },
-
- inputRow: {
- display: "flex",
- alignItems: "stretch",
- width: "100%",
- },
- inputInRow: {
- flex: 1,
- padding: 10,
- borderRadius: "12px 0 0 12px",
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(10,10,12,0.60)",
- color: stylesTokens.textMain,
- outline: "none",
- minWidth: 0,
- fontSize: 16,
- },
- pwToggleBtn: {
- width: 48,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- borderRadius: "0 12px 12px 0",
- border: `1px solid rgba(233,216,166,0.18)`,
- borderLeft: "none",
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- cursor: "pointer",
- fontWeight: 900,
- padding: 0,
- },
-
- // Background
- bgFixed: {
- position: "fixed",
- top: 0,
- left: 0,
- width: "100vw",
- height: "100dvh",
- zIndex: -1,
- pointerEvents: "none",
- transform: "translateZ(0)",
- backfaceVisibility: "hidden",
- willChange: "transform",
- },
-
- bgMap: {
- position: "absolute",
- inset: 0,
- backgroundImage: 'url("/bg/marauders-map-blur.jpg")',
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
- },
-
- chipGrid: {
- marginTop: 12,
- display: "grid",
- gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
- gap: 8,
- },
-
- chipBtn: {
- padding: "10px 14px",
- borderRadius: 12,
- border: "1px solid rgba(233,216,166,0.18)",
- background: "rgba(255,255,255,0.06)",
- color: stylesTokens.textGold,
- fontWeight: 1000,
- cursor: "pointer",
- minWidth: 64,
- },
-
- userBtn: {
- display: "inline-flex",
- alignItems: "center",
- gap: 8,
- padding: "10px 12px",
- borderRadius: 12,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "rgba(255,255,255,0.05)",
- color: stylesTokens.textMain,
- fontWeight: 900,
- cursor: "pointer",
- boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
- maxWidth: 180,
- },
-
- userDropdown: {
- position: "absolute",
- right: 0,
- top: "calc(100% + 8px)",
- minWidth: 220,
- borderRadius: 14,
- border: `1px solid rgba(233,216,166,0.18)`,
- background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
- boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
- overflow: "hidden",
- zIndex: 10000,
- backdropFilter: "blur(8px)",
- },
-
- userDropdownItem: {
- width: "100%",
- textAlign: "left",
- padding: "10px 12px",
- border: "none",
- background: "transparent",
- color: stylesTokens.textMain,
- fontWeight: 900,
- cursor: "pointer",
- },
-
- userDropdownDivider: {
- height: 1,
- background: "rgba(233,216,166,0.12)",
- },
-
-};
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..7926117
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,11 @@
+import { API_BASE } from "../constants";
+
+export async function api(path, opts = {}) {
+ const res = await fetch(API_BASE + path, {
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ ...opts,
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
\ No newline at end of file
diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx
new file mode 100644
index 0000000..a49ac46
--- /dev/null
+++ b/frontend/src/components/AdminPanel.jsx
@@ -0,0 +1,136 @@
+import React, { useEffect, useState } from "react";
+import { api } from "../api/client";
+import { styles } from "../styles/styles";
+import { stylesTokens } from "../styles/theme";
+
+export default function AdminPanel() {
+ const [users, setUsers] = useState([]);
+
+ const [open, setOpen] = useState(false);
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [role, setRole] = useState("user");
+ const [msg, setMsg] = useState("");
+
+ const loadUsers = async () => {
+ const u = await api("/admin/users");
+ setUsers(u);
+ };
+
+ useEffect(() => {
+ loadUsers().catch(() => {});
+ }, []);
+
+ const resetForm = () => {
+ setEmail("");
+ setPassword("");
+ setRole("user");
+ };
+
+ const createUser = async () => {
+ setMsg("");
+ try {
+ await api("/admin/users", {
+ method: "POST",
+ body: JSON.stringify({ email, password, role }),
+ });
+ setMsg("✅ User erstellt.");
+ await loadUsers();
+ resetForm();
+ setOpen(false);
+ } catch (e) {
+ setMsg("❌ Fehler: " + (e?.message || "unknown"));
+ }
+ };
+
+ const closeModal = () => {
+ setOpen(false);
+ setMsg("");
+ };
+
+ return (
+
+
+
Admin Dashboard
+
+
+
+
+ Vorhandene User
+
+
+
+ {users.map((u) => (
+
+
{u.email}
+
+ {u.role}
+
+
+ {u.disabled ? "disabled" : "active"}
+
+
+ ))}
+
+
+ {open && (
+
+
e.stopPropagation()}>
+
+
+ Neuen User anlegen
+
+
+
+
+
+
setEmail(e.target.value)}
+ placeholder="Email"
+ style={styles.input}
+ autoFocus
+ />
+
setPassword(e.target.value)}
+ placeholder="Initial Passwort"
+ type="password"
+ style={styles.input}
+ />
+
+
+ {msg &&
{msg}
}
+
+
+
+
+
+
+
+ Tipp: Klick auf Item: Grün → Rot → Grau → Leer
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/ChipModal.jsx b/frontend/src/components/ChipModal.jsx
new file mode 100644
index 0000000..3f40502
--- /dev/null
+++ b/frontend/src/components/ChipModal.jsx
@@ -0,0 +1,35 @@
+import React from "react";
+import { styles } from "../styles/styles";
+import { stylesTokens } from "../styles/theme";
+import { CHIP_LIST } from "../constants";
+
+export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
+ if (!chipOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Wer hat die Karte?
+
+
+
+
Chip auswählen:
+
+
+ {CHIP_LIST.map((c) => (
+
+ ))}
+
+
+
+ Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von s zurück auf —.
+
+
+
+ );
+}
diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx
new file mode 100644
index 0000000..1c75807
--- /dev/null
+++ b/frontend/src/components/LoginPage.jsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { styles } from "../styles/styles";
+
+export default function LoginPage({
+ loginEmail,
+ setLoginEmail,
+ loginPassword,
+ setLoginPassword,
+ showPw,
+ setShowPw,
+ doLogin,
+}) {
+ return (
+
+
+
+
+
+
+
Zauber-Detektiv Notizbogen
+
+
Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen
+
+
+
+ setLoginEmail(e.target.value)}
+ placeholder="Email"
+ style={styles.loginInput}
+ inputMode="email"
+ autoComplete="username"
+ />
+
+
+
+
+ setLoginPassword(e.target.value)}
+ placeholder="Passwort"
+ type={showPw ? "text" : "password"}
+ style={styles.inputInRow}
+ autoComplete="current-password"
+ />
+
+
+
+
+
+
+
+
+ Deine Notizen bleiben privat – jeder Spieler sieht nur seinen eigenen Zettel.
+
+
+
+ );
+}
diff --git a/frontend/src/components/PasswordModal.jsx b/frontend/src/components/PasswordModal.jsx
new file mode 100644
index 0000000..75f563c
--- /dev/null
+++ b/frontend/src/components/PasswordModal.jsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { styles } from "../styles/styles";
+import { stylesTokens } from "../styles/theme";
+
+export default function PasswordModal({
+ pwOpen,
+ closePwModal,
+ pw1,
+ setPw1,
+ pw2,
+ setPw2,
+ pwMsg,
+ pwSaving,
+ savePassword,
+}) {
+ if (!pwOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Passwort setzen
+
+
+
+
+
setPw1(e.target.value)}
+ placeholder="Neues Passwort"
+ type="password"
+ style={styles.input}
+ autoFocus
+ />
+
setPw2(e.target.value)}
+ placeholder="Neues Passwort wiederholen"
+ type="password"
+ style={styles.input}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") savePassword();
+ }}
+ />
+
+ {pwMsg &&
{pwMsg}
}
+
+
+
+
+
+
+
+ Hinweis: Mindestens 8 Zeichen empfohlen.
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx
new file mode 100644
index 0000000..f062b09
--- /dev/null
+++ b/frontend/src/components/TopBar.jsx
@@ -0,0 +1,59 @@
+import React from "react";
+import { styles } from "../styles/styles";
+import { stylesTokens } from "../styles/theme";
+
+export default function TopBar({
+ me,
+ userMenuOpen,
+ setUserMenuOpen,
+ openPwModal,
+ doLogout,
+ newGame,
+}) {
+ return (
+
+
+
{me.email}
+
{me.role}
+
+
+
+
+
+
+ {userMenuOpen && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
new file mode 100644
index 0000000..a44edd6
--- /dev/null
+++ b/frontend/src/constants.js
@@ -0,0 +1,2 @@
+export const API_BASE = "/api";
+export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
\ No newline at end of file
diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js
new file mode 100644
index 0000000..85edad3
--- /dev/null
+++ b/frontend/src/styles/hooks/useHpGlobalStyles.js
@@ -0,0 +1,84 @@
+import { useEffect } from "react";
+import { stylesTokens } from "../theme";
+
+export function useHpGlobalStyles() {
+ // Google Fonts
+ useEffect(() => {
+ if (document.getElementById("hp-fonts")) return;
+
+ const pre1 = document.createElement("link");
+ pre1.id = "hp-fonts-pre1";
+ pre1.rel = "preconnect";
+ pre1.href = "https://fonts.googleapis.com";
+
+ const pre2 = document.createElement("link");
+ pre2.id = "hp-fonts-pre2";
+ pre2.rel = "preconnect";
+ pre2.href = "https://fonts.gstatic.com";
+ pre2.crossOrigin = "anonymous";
+
+ const link = document.createElement("link");
+ link.id = "hp-fonts";
+ link.rel = "stylesheet";
+ link.href =
+ "https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&family=IM+Fell+English:ital,wght@0,400;0,700;1,400&display=swap";
+
+ document.head.appendChild(pre1);
+ document.head.appendChild(pre2);
+ document.head.appendChild(link);
+ }, []);
+
+ // Keyframes
+ useEffect(() => {
+ if (document.getElementById("hp-anim-style")) return;
+ const style = document.createElement("style");
+ style.id = "hp-anim-style";
+ style.innerHTML = `
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+ @keyframes popIn { from { opacity: 0; transform: translateY(8px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
+ @keyframes rowPulse { 0%{ transform: scale(1); } 50%{ transform: scale(1.01); } 100%{ transform: scale(1); } }
+ @keyframes candleGlow {
+ 0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
+ 35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
+ 70% { opacity: .62; transform: translateY(1px) scale(1.01); filter: blur(17px); }
+ 100% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
+ }
+ `;
+ document.head.appendChild(style);
+ }, []);
+
+ // html/body reset
+ useEffect(() => {
+ document.documentElement.style.height = "100%";
+ document.body.style.height = "100%";
+ document.documentElement.style.margin = "0";
+ document.body.style.margin = "0";
+ document.documentElement.style.padding = "0";
+ document.body.style.padding = "0";
+ }, []);
+
+ // Global CSS
+ useEffect(() => {
+ if (document.getElementById("hp-global-style")) return;
+ const style = document.createElement("style");
+ style.id = "hp-global-style";
+ style.innerHTML = `
+ html, body {
+ overscroll-behavior-y: none;
+ -webkit-text-size-adjust: 100%;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background: ${stylesTokens.pageBg};
+ color: ${stylesTokens.textMain};
+ }
+ body {
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ }
+ #root { background: transparent; }
+ * { -webkit-tap-highlight-color: transparent; }
+ `;
+ document.head.appendChild(style);
+ }, []);
+}
\ No newline at end of file
diff --git a/frontend/src/styles/styles.js b/frontend/src/styles/styles.js
new file mode 100644
index 0000000..6687ca2
--- /dev/null
+++ b/frontend/src/styles/styles.js
@@ -0,0 +1,490 @@
+import { stylesTokens } from "./theme";
+
+export const styles = {
+ page: {
+ minHeight: "100dvh",
+ background: "transparent",
+ position: "relative",
+ zIndex: 1,
+ },
+
+ shell: {
+ fontFamily: '"IM Fell English", system-ui',
+ padding: 16,
+ maxWidth: 680,
+ margin: "0 auto",
+ },
+
+ topBar: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: 10,
+ padding: 12,
+ borderRadius: 16,
+ background: stylesTokens.panelBg,
+ border: `1px solid ${stylesTokens.panelBorder}`,
+ boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
+ backdropFilter: "blur(6px)",
+ },
+
+ card: {
+ borderRadius: 18,
+ overflow: "hidden",
+ border: `1px solid ${stylesTokens.panelBorder}`,
+ background: "rgba(18, 18, 20, 0.50)",
+ boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
+ },
+
+ cardBody: {
+ padding: 12,
+ display: "flex",
+ gap: 10,
+ alignItems: "center",
+ },
+
+ sectionHeader: {
+ padding: "11px 14px",
+ fontWeight: 1000,
+ fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
+ letterSpacing: 1.0,
+ color: stylesTokens.textGold,
+ background: "linear-gradient(180deg, rgba(32,32,36,0.92), rgba(14,14,16,0.92))",
+ borderBottom: `1px solid ${stylesTokens.goldLine}`,
+ textTransform: "uppercase",
+ textShadow: "0 1px 0 rgba(0,0,0,0.6)",
+ },
+
+ row: {
+ display: "grid",
+ gridTemplateColumns: "1fr 54px 68px",
+ gap: 10,
+ padding: "12px 14px",
+ alignItems: "center",
+ borderBottom: "1px solid rgba(233,216,166,0.08)",
+ borderLeft: "4px solid rgba(0,0,0,0)",
+ },
+
+ name: {
+ cursor: "pointer",
+ userSelect: "none",
+ fontWeight: 800,
+ letterSpacing: 0.2,
+ color: stylesTokens.textMain,
+ },
+
+ statusCell: {
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+
+ statusBadge: {
+ width: 34,
+ height: 34,
+ display: "inline-flex",
+ justifyContent: "center",
+ alignItems: "center",
+ borderRadius: 999,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ fontWeight: 1100,
+ fontSize: 16,
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
+ },
+
+ tagBtn: {
+ padding: "8px 0",
+ fontWeight: 1000,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ cursor: "pointer",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
+ },
+
+ helpBtn: {
+ padding: "10px 12px",
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ fontWeight: 1000,
+ cursor: "pointer",
+ whiteSpace: "nowrap",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
+ },
+
+ input: {
+ width: "100%",
+ padding: 10,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(10,10,12,0.55)",
+ color: stylesTokens.textMain,
+ outline: "none",
+ fontSize: 16,
+ },
+
+ primaryBtn: {
+ padding: "10px 12px",
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.28)`,
+ background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
+ color: stylesTokens.textGold,
+ fontWeight: 1000,
+ cursor: "pointer",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
+ },
+
+ secondaryBtn: {
+ padding: "10px 12px",
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.05)",
+ color: stylesTokens.textMain,
+ fontWeight: 900,
+ cursor: "pointer",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
+ },
+
+ // Admin
+ adminWrap: {
+ marginTop: 14,
+ padding: 12,
+ borderRadius: 16,
+ border: `1px solid rgba(233,216,166,0.14)`,
+ background: "rgba(18, 18, 20, 0.40)",
+ boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
+ backdropFilter: "blur(6px)",
+ },
+ adminTop: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 10,
+ },
+ adminTitle: {
+ fontWeight: 1000,
+ color: stylesTokens.textGold,
+ },
+ userRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr 80px 90px",
+ gap: 8,
+ padding: 10,
+ borderRadius: 12,
+ background: "rgba(255,255,255,0.06)",
+ border: `1px solid rgba(233,216,166,0.10)`,
+ },
+
+ // Modal
+ modalOverlay: {
+ position: "fixed",
+ inset: 0,
+ background: "rgba(0,0,0,0.65)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 16,
+ zIndex: 9999,
+ animation: "fadeIn 160ms ease-out",
+ },
+ modalCard: {
+ width: "100%",
+ maxWidth: 560,
+ borderRadius: 18,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "linear-gradient(180deg, rgba(20,20,24,0.92), rgba(12,12,14,0.86))",
+ boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
+ padding: 14,
+ backdropFilter: "blur(6px)",
+ animation: "popIn 160ms ease-out",
+ color: stylesTokens.textMain,
+ },
+ modalHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: 10,
+ },
+ modalCloseBtn: {
+ width: 38,
+ height: 38,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ fontWeight: 1000,
+ cursor: "pointer",
+ lineHeight: "38px",
+ textAlign: "center",
+ },
+
+ // Help
+ helpBody: {
+ marginTop: 10,
+ paddingTop: 4,
+ maxHeight: "70vh",
+ overflow: "auto",
+ },
+ helpSectionTitle: {
+ fontWeight: 1000,
+ color: stylesTokens.textGold,
+ marginTop: 10,
+ marginBottom: 6,
+ },
+ helpText: {
+ color: stylesTokens.textMain,
+ opacity: 0.92,
+ lineHeight: 1.35,
+ },
+ helpList: {
+ marginTop: 10,
+ display: "grid",
+ gap: 8,
+ },
+ helpListRow: {
+ display: "grid",
+ gridTemplateColumns: "42px 1fr",
+ gap: 10,
+ alignItems: "center",
+ color: stylesTokens.textMain,
+ },
+ helpBadge: {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 38,
+ height: 38,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ fontWeight: 1100,
+ fontSize: 18,
+ },
+ helpMiniTag: {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 38,
+ height: 38,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ fontWeight: 1100,
+ },
+ helpDivider: {
+ margin: "14px 0",
+ height: 1,
+ background: "rgba(233,216,166,0.12)",
+ },
+
+ // Login
+ loginPage: {
+ minHeight: "100dvh",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ position: "relative",
+ overflow: "hidden",
+ padding: 20,
+ background: "transparent",
+ zIndex: 1,
+ },
+ loginCard: {
+ width: "100%",
+ maxWidth: 420,
+ padding: 26,
+ borderRadius: 22,
+ position: "relative",
+ zIndex: 2,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(18, 18, 20, 0.55)",
+ boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
+ backdropFilter: "blur(8px)",
+ animation: "popIn 240ms ease-out",
+ color: stylesTokens.textMain,
+ },
+ loginTitle: {
+ fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
+ fontWeight: 1000,
+ fontSize: 26,
+ color: stylesTokens.textGold,
+ textAlign: "center",
+ letterSpacing: 0.6,
+ },
+ loginSubtitle: {
+ marginTop: 6,
+ textAlign: "center",
+ color: stylesTokens.textMain,
+ opacity: 0.9,
+ fontSize: 15,
+ lineHeight: 1.4,
+ },
+ loginFieldWrap: {
+ width: "100%",
+ display: "flex",
+ justifyContent: "center",
+ },
+ loginInput: {
+ width: "100%",
+ padding: 10,
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(10,10,12,0.60)",
+ color: stylesTokens.textMain,
+ outline: "none",
+ fontSize: 16,
+ },
+ loginBtn: {
+ padding: "12px 14px",
+ borderRadius: 14,
+ border: `1px solid rgba(233,216,166,0.28)`,
+ background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
+ color: stylesTokens.textGold,
+ fontWeight: 1000,
+ fontSize: 16,
+ cursor: "pointer",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
+ },
+ loginHint: {
+ marginTop: 18,
+ fontSize: 13,
+ opacity: 0.78,
+ textAlign: "center",
+ color: stylesTokens.textDim,
+ lineHeight: 1.35,
+ },
+
+ candleGlowLayer: {
+ position: "absolute",
+ inset: 0,
+ pointerEvents: "none",
+ background: `
+ radial-gradient(circle at 20% 25%, rgba(255, 200, 120, 0.16), rgba(0,0,0,0) 40%),
+ radial-gradient(circle at 80% 30%, rgba(255, 210, 140, 0.12), rgba(0,0,0,0) 42%),
+ radial-gradient(circle at 55% 75%, rgba(255, 180, 100, 0.08), rgba(0,0,0,0) 45%)
+ `,
+ animation: "candleGlow 3.8s ease-in-out infinite",
+ mixBlendMode: "multiply",
+ },
+
+ inputRow: {
+ display: "flex",
+ alignItems: "stretch",
+ width: "100%",
+ },
+ inputInRow: {
+ flex: 1,
+ padding: 10,
+ borderRadius: "12px 0 0 12px",
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(10,10,12,0.60)",
+ color: stylesTokens.textMain,
+ outline: "none",
+ minWidth: 0,
+ fontSize: 16,
+ },
+ pwToggleBtn: {
+ width: 48,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: "0 12px 12px 0",
+ border: `1px solid rgba(233,216,166,0.18)`,
+ borderLeft: "none",
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ cursor: "pointer",
+ fontWeight: 900,
+ padding: 0,
+ },
+
+ // Background
+ bgFixed: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ width: "100vw",
+ height: "100dvh",
+ zIndex: -1,
+ pointerEvents: "none",
+ transform: "translateZ(0)",
+ backfaceVisibility: "hidden",
+ willChange: "transform",
+ },
+
+ bgMap: {
+ position: "absolute",
+ inset: 0,
+ backgroundImage: 'url("/bg/marauders-map-blur.jpg")',
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
+ },
+
+ chipGrid: {
+ marginTop: 12,
+ display: "grid",
+ gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
+ gap: 8,
+ },
+
+ chipBtn: {
+ padding: "10px 14px",
+ borderRadius: 12,
+ border: "1px solid rgba(233,216,166,0.18)",
+ background: "rgba(255,255,255,0.06)",
+ color: stylesTokens.textGold,
+ fontWeight: 1000,
+ cursor: "pointer",
+ minWidth: 64,
+ },
+
+ userBtn: {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "10px 12px",
+ borderRadius: 12,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "rgba(255,255,255,0.05)",
+ color: stylesTokens.textMain,
+ fontWeight: 900,
+ cursor: "pointer",
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
+ maxWidth: 180,
+ },
+
+ userDropdown: {
+ position: "absolute",
+ right: 0,
+ top: "calc(100% + 8px)",
+ minWidth: 220,
+ borderRadius: 14,
+ border: `1px solid rgba(233,216,166,0.18)`,
+ background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
+ boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
+ overflow: "hidden",
+ zIndex: 10000,
+ backdropFilter: "blur(8px)",
+ },
+
+ userDropdownItem: {
+ width: "100%",
+ textAlign: "left",
+ padding: "10px 12px",
+ border: "none",
+ background: "transparent",
+ color: stylesTokens.textMain,
+ fontWeight: 900,
+ cursor: "pointer",
+ },
+
+ userDropdownDivider: {
+ height: 1,
+ background: "rgba(233,216,166,0.12)",
+ },
+};
\ No newline at end of file
diff --git a/frontend/src/styles/theme.js b/frontend/src/styles/theme.js
new file mode 100644
index 0000000..60c18ad
--- /dev/null
+++ b/frontend/src/styles/theme.js
@@ -0,0 +1,11 @@
+export const stylesTokens = {
+ pageBg: "#0b0b0c",
+ panelBg: "rgba(20, 20, 22, 0.55)",
+ panelBorder: "rgba(233, 216, 166, 0.14)",
+
+ textMain: "rgba(245, 239, 220, 0.92)",
+ textDim: "rgba(233, 216, 166, 0.70)",
+ textGold: "#e9d8a6",
+
+ goldLine: "rgba(233, 216, 166, 0.18)",
+ };
\ No newline at end of file
diff --git a/frontend/src/utils/chipStorage.js b/frontend/src/utils/chipStorage.js
new file mode 100644
index 0000000..a737d66
--- /dev/null
+++ b/frontend/src/utils/chipStorage.js
@@ -0,0 +1,23 @@
+function chipStorageKey(gameId, entryId) {
+ return `chip:${gameId}:${entryId}`;
+ }
+
+ export function getChipLS(gameId, entryId) {
+ try {
+ return localStorage.getItem(chipStorageKey(gameId, entryId));
+ } catch {
+ return null;
+ }
+ }
+
+ export function setChipLS(gameId, entryId, chip) {
+ try {
+ localStorage.setItem(chipStorageKey(gameId, entryId), chip);
+ } catch {}
+ }
+
+ export function clearChipLS(gameId, entryId) {
+ try {
+ localStorage.removeItem(chipStorageKey(gameId, entryId));
+ } catch {}
+ }
\ No newline at end of file
diff --git a/frontend/src/utils/cycleTag.js b/frontend/src/utils/cycleTag.js
new file mode 100644
index 0000000..deee589
--- /dev/null
+++ b/frontend/src/utils/cycleTag.js
@@ -0,0 +1,11 @@
+/**
+ * Backend erlaubt: null | "i" | "m" | "s"
+ * Rotation:
+ * null -> i -> m -> s (Popup) -> null
+ */
+export function cycleTag(tag) {
+ if (!tag) return "i";
+ if (tag === "i") return "m";
+ if (tag === "m") return "s";
+ return null;
+}
\ No newline at end of file