Refactor app by modularizing components and extracting utilities.
The changes split large features into smaller, reusable components like `AdminPanel`, `LoginPage`, `TopBar`, `PasswordModal`, and `ChipModal`. Utility functions such as `cycleTag` and `chipStorage` were extracted for better organization. This improves the code's readability, maintainability, and scalability.
This commit is contained in:
1272
frontend/src/App.jsx
1272
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
11
frontend/src/api/client.js
Normal file
11
frontend/src/api/client.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
136
frontend/src/components/AdminPanel.jsx
Normal file
136
frontend/src/components/AdminPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.adminWrap}>
|
||||||
|
<div style={styles.adminTop}>
|
||||||
|
<div style={styles.adminTitle}>Admin Dashboard</div>
|
||||||
|
<button onClick={() => setOpen(true)} style={styles.primaryBtn}>
|
||||||
|
+ User anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
|
||||||
|
Vorhandene User
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} style={styles.userRow}>
|
||||||
|
<div style={{ color: stylesTokens.textMain }}>{u.email}</div>
|
||||||
|
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
|
||||||
|
{u.role}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
|
||||||
|
{u.disabled ? "disabled" : "active"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={styles.modalOverlay} onMouseDown={closeModal}>
|
||||||
|
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div style={styles.modalHeader}>
|
||||||
|
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
|
||||||
|
Neuen User anlegen
|
||||||
|
</div>
|
||||||
|
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||||
|
<input
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="Email"
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Initial Passwort"
|
||||||
|
type="password"
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setMsg("");
|
||||||
|
}}
|
||||||
|
style={styles.secondaryBtn}
|
||||||
|
>
|
||||||
|
Leeren
|
||||||
|
</button>
|
||||||
|
<button onClick={createUser} style={styles.primaryBtn}>
|
||||||
|
User erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||||
|
Tipp: Klick auf Item: Grün → Rot → Grau → Leer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/ChipModal.jsx
Normal file
35
frontend/src/components/ChipModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
|
||||||
|
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div style={styles.modalHeader}>
|
||||||
|
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
|
||||||
|
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
|
||||||
|
|
||||||
|
<div style={styles.chipGrid}>
|
||||||
|
{CHIP_LIST.map((c) => (
|
||||||
|
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
|
||||||
|
Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von <b>s</b> zurück auf —.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/components/LoginPage.jsx
Normal file
71
frontend/src/components/LoginPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.loginPage}>
|
||||||
|
<div style={styles.bgFixed} aria-hidden="true">
|
||||||
|
<div style={styles.bgMap} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.candleGlowLayer} aria-hidden="true" />
|
||||||
|
|
||||||
|
<div style={styles.loginCard}>
|
||||||
|
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
|
||||||
|
|
||||||
|
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
|
||||||
|
<div style={styles.loginFieldWrap}>
|
||||||
|
<input
|
||||||
|
value={loginEmail}
|
||||||
|
onChange={(e) => setLoginEmail(e.target.value)}
|
||||||
|
placeholder="Email"
|
||||||
|
style={styles.loginInput}
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.loginFieldWrap}>
|
||||||
|
<div style={styles.inputRow}>
|
||||||
|
<input
|
||||||
|
value={loginPassword}
|
||||||
|
onChange={(e) => setLoginPassword(e.target.value)}
|
||||||
|
placeholder="Passwort"
|
||||||
|
type={showPw ? "text" : "password"}
|
||||||
|
style={styles.inputInRow}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPw((v) => !v)}
|
||||||
|
style={styles.pwToggleBtn}
|
||||||
|
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
|
||||||
|
title={showPw ? "Verstecken" : "Anzeigen"}
|
||||||
|
>
|
||||||
|
{showPw ? "🙈" : "👁"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={doLogin} style={styles.loginBtn}>
|
||||||
|
✦ Anmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.loginHint}>
|
||||||
|
Deine Notizen bleiben privat – jeder Spieler sieht nur seinen eigenen Zettel.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/PasswordModal.jsx
Normal file
66
frontend/src/components/PasswordModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
|
||||||
|
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div style={styles.modalHeader}>
|
||||||
|
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
|
||||||
|
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||||
|
<input
|
||||||
|
value={pw1}
|
||||||
|
onChange={(e) => setPw1(e.target.value)}
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
type="password"
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={pw2}
|
||||||
|
onChange={(e) => setPw2(e.target.value)}
|
||||||
|
placeholder="Neues Passwort wiederholen"
|
||||||
|
type="password"
|
||||||
|
style={styles.input}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") savePassword();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pwMsg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{pwMsg}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
|
||||||
|
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
|
||||||
|
{pwSaving ? "Speichern..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||||
|
Hinweis: Mindestens 8 Zeichen empfohlen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/TopBar.jsx
Normal file
59
frontend/src/components/TopBar.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={styles.topBar}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>{me.email}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.role}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }} data-user-menu>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setUserMenuOpen((v) => !v)}
|
||||||
|
style={styles.userBtn}
|
||||||
|
title="User Menü"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1 }}>👤</span>
|
||||||
|
<span>Account</span>
|
||||||
|
<span style={{ opacity: 0.8 }}>▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div style={styles.userDropdown}>
|
||||||
|
<button onClick={openPwModal} style={styles.userDropdownItem}>
|
||||||
|
Passwort setzen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={styles.userDropdownDivider} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
doLogout();
|
||||||
|
}}
|
||||||
|
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={newGame} style={styles.primaryBtn}>
|
||||||
|
+ Neues Spiel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
frontend/src/constants.js
Normal file
2
frontend/src/constants.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const API_BASE = "/api";
|
||||||
|
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
|
||||||
84
frontend/src/styles/hooks/useHpGlobalStyles.js
Normal file
84
frontend/src/styles/hooks/useHpGlobalStyles.js
Normal file
@@ -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);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
490
frontend/src/styles/styles.js
Normal file
490
frontend/src/styles/styles.js
Normal file
@@ -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)",
|
||||||
|
},
|
||||||
|
};
|
||||||
11
frontend/src/styles/theme.js
Normal file
11
frontend/src/styles/theme.js
Normal file
@@ -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)",
|
||||||
|
};
|
||||||
23
frontend/src/utils/chipStorage.js
Normal file
23
frontend/src/utils/chipStorage.js
Normal file
@@ -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 {}
|
||||||
|
}
|
||||||
11
frontend/src/utils/cycleTag.js
Normal file
11
frontend/src/utils/cycleTag.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user