import React, { useEffect, useState } from "react"; const API = "/api"; const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"]; 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() { const [me, setMe] = useState(null); const [loginEmail, setLoginEmail] = useState(""); const [loginPassword, setLoginPassword] = useState(""); const [showPw, setShowPw] = useState(false); const [games, setGames] = useState([]); const [gameId, setGameId] = useState(null); 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); const load = async () => { const m = await api("/auth/me"); setMe(m); const gs = await api("/games"); setGames(gs); if (gs[0] && !gameId) setGameId(gs[0].id); }; // 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 } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { (async () => { if (!gameId) return; try { const sh = await api(`/games/${gameId}/sheet`); setSheet(sh); } catch { // ignore } })(); }, [gameId]); const doLogin = async () => { await api("/auth/login", { method: "POST", body: JSON.stringify({ email: loginEmail, password: loginPassword }), }); await load(); }; const doLogout = async () => { await api("/auth/logout", { method: "POST" }); setMe(null); setGames([]); setGameId(null); setSheet(null); }; const newGame = async () => { const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), }); const gs = await api("/games"); setGames(gs); setGameId(g.id); }; const reloadSheet = async () => { if (!gameId) return; const sh = await api(`/games/${gameId}/sheet`); setSheet(sh); }; 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); }; // 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); } await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", body: JSON.stringify({ note_tag: next }), }); 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" }), }); } finally { await reloadSheet(); } }; // 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 }), }); } finally { await reloadSheet(); } }; // 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 t; // i oder m }; // --- 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 ===== if (!me) { return (