Merge pull request 'dev' (#3) from dev into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-02-06 09:43:03 +00:00
15 changed files with 537 additions and 84 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

View File

@@ -4,9 +4,14 @@ import React, { useEffect, useState } from "react";
import { api } from "./api/client"; import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag"; import { cycleTag } from "./utils/cycleTag";
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles"; import { styles } from "./styles/styles";
import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
import AdminPanel from "./components/AdminPanel"; import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage"; import LoginPage from "./components/LoginPage";
import TopBar from "./components/TopBar"; import TopBar from "./components/TopBar";
@@ -15,6 +20,9 @@ import ChipModal from "./components/ChipModal";
import HelpModal from "./components/HelpModal"; import HelpModal from "./components/HelpModal";
import GamePickerCard from "./components/GamePickerCard"; import GamePickerCard from "./components/GamePickerCard";
import SheetSection from "./components/SheetSection"; import SheetSection from "./components/SheetSection";
import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
@@ -31,6 +39,9 @@ export default function App() {
const [sheet, setSheet] = useState(null); const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null); const [pulseId, setPulseId] = useState(null);
// Winner (per game)
const [winnerName, setWinnerName] = useState("");
// Modals // Modals
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
@@ -45,11 +56,20 @@ export default function App() {
const [pwMsg, setPwMsg] = useState(""); const [pwMsg, setPwMsg] = useState("");
const [pwSaving, setPwSaving] = useState(false); const [pwSaving, setPwSaving] = useState(false);
// Theme
const [designOpen, setDesignOpen] = useState(false);
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
// ===== Data loaders ===== // ===== Data loaders =====
const load = async () => { const load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
// Theme pro User laden & anwenden
const tk = loadThemeKey(m?.email);
setThemeKey(tk);
applyTheme(tk);
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
@@ -86,15 +106,19 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// load sheet when game changes // load sheet + winner when game changes
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!gameId) return; if (!gameId) return;
try { try {
await reloadSheet(); await reloadSheet();
} catch { } catch {
// ignore // ignore
} }
// Sieger pro Game aus localStorage laden
setWinnerName(getWinnerLS(gameId));
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
@@ -114,6 +138,7 @@ export default function App() {
setGames([]); setGames([]);
setGameId(null); setGameId(null);
setSheet(null); setSheet(null);
setWinnerName("");
}; };
// ===== Password change ===== // ===== Password change =====
@@ -153,15 +178,47 @@ export default function App() {
} }
}; };
// ===== Theme actions =====
const openDesignModal = () => {
setDesignOpen(true);
setUserMenuOpen(false);
};
const selectTheme = (key) => {
setThemeKey(key);
applyTheme(key);
saveThemeKey(me?.email, key);
};
// ===== Game actions ===== // ===== Game actions =====
const newGame = async () => { const newGame = async () => {
const g = await api("/games", { const g = await api("/games", {
method: "POST", method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
}); });
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
setGameId(g.id); setGameId(g.id);
// Neues Spiel -> Sieger leer
clearWinnerLS(g.id);
setWinnerName("");
};
// ===== Winner actions =====
const saveWinner = () => {
if (!gameId) return;
const v = (winnerName || "").trim();
if (!v) {
clearWinnerLS(gameId);
setWinnerName("");
return;
}
setWinnerLS(gameId, v);
setWinnerName(v);
}; };
// ===== Sheet actions ===== // ===== Sheet actions =====
@@ -185,14 +242,12 @@ export default function App() {
const toggleTag = async (entry) => { const toggleTag = async (entry) => {
const next = cycleTag(entry.note_tag); const next = cycleTag(entry.note_tag);
// going to "s" -> open chip modal, don't write backend yet
if (next === "s") { if (next === "s") {
setChipEntry(entry); setChipEntry(entry);
setChipOpen(true); setChipOpen(true);
return; return;
} }
// s -> — : clear local chip
if (next === null) clearChipLS(gameId, entry.entry_id); if (next === null) clearChipLS(gameId, entry.entry_id);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
@@ -210,11 +265,9 @@ export default function App() {
setChipOpen(false); setChipOpen(false);
setChipEntry(null); setChipEntry(null);
// frontend-only save
setChipLS(gameId, entry.entry_id, chip); setChipLS(gameId, entry.entry_id, chip);
try { try {
// backend only gets "s"
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ note_tag: "s" }), body: JSON.stringify({ note_tag: "s" }),
@@ -253,7 +306,7 @@ export default function App() {
const chip = getChipLS(gameId, entry.entry_id); const chip = getChipLS(gameId, entry.entry_id);
return chip ? `s.${chip}` : "s"; return chip ? `s.${chip}` : "s";
} }
return t; // i oder m return t;
}; };
// ===== Login page ===== // ===== Login page =====
@@ -291,6 +344,7 @@ export default function App() {
userMenuOpen={userMenuOpen} userMenuOpen={userMenuOpen}
setUserMenuOpen={setUserMenuOpen} setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal} openPwModal={openPwModal}
openDesignModal={openDesignModal}
doLogout={doLogout} doLogout={doLogout}
newGame={newGame} newGame={newGame}
/> />
@@ -306,6 +360,9 @@ export default function App() {
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} /> <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Sieger Badge: nur wenn gesetzt */}
<WinnerBadge winner={(winnerName || "").trim()} />
<div style={{ marginTop: 14, display: "grid", gap: 14 }}> <div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => ( {sections.map((sec) => (
<SheetSection <SheetSection
@@ -320,6 +377,9 @@ export default function App() {
))} ))}
</div> </div>
{/* Sieger ganz unten */}
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} />
<div style={{ height: 24 }} /> <div style={{ height: 24 }} />
</div> </div>
@@ -335,6 +395,16 @@ export default function App() {
savePassword={savePassword} savePassword={savePassword}
/> />
<DesignModal
open={designOpen}
onClose={() => setDesignOpen(false)}
themeKey={themeKey}
onSelect={(k) => {
selectTheme(k);
setDesignOpen(false);
}}
/>
<ChipModal <ChipModal
chipOpen={chipOpen} chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash} closeChipModalToDash={closeChipModalToDash}

View File

@@ -0,0 +1,57 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { THEMES } from "../styles/themes";
export default function DesignModal({ open, onClose, themeKey, onSelect }) {
if (!open) return null;
const themeEntries = Object.entries(THEMES);
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Design ändern</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}>
Wähle dein Theme:
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
{themeEntries.map(([key, t]) => {
const active = key === themeKey;
return (
<button
key={key}
onClick={() => onSelect(key)}
style={{
...styles.secondaryBtn,
textAlign: "left",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
border: active
? `1px solid rgba(233,216,166,0.40)`
: `1px solid rgba(233,216,166,0.18)`,
}}
>
<span style={{ fontWeight: 1000 }}>{t.label}</span>
<span style={{ opacity: 0.75 }}>{active ? "✓ aktiv" : ""}</span>
</button>
);
})}
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Das Design wird pro User gespeichert (Email).
</div>
</div>
</div>
);
}

View File

@@ -3,15 +3,6 @@ import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
/**
* props:
* - title: string
* - entries: array
* - pulseId: number | null
* - onCycleStatus(entry): fn
* - onToggleTag(entry): fn
* - displayTag(entry): string
*/
export default function SheetSection({ export default function SheetSection({
title, title,
entries, entries,
@@ -20,18 +11,18 @@ export default function SheetSection({
onToggleTag, onToggleTag,
displayTag, displayTag,
}) { }) {
// --- helpers (lokal, weil sie rein UI sind) ---
const getRowBg = (status) => { const getRowBg = (status) => {
if (status === 1) return "rgba(255, 35, 35, 0.16)"; if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return "rgba(0, 190, 80, 0.16)"; if (status === 2) return stylesTokens.rowOkBg;
if (status === 3) return "rgba(140, 140, 140, 0.12)"; if (status === 3) return stylesTokens.rowMaybeBg;
return "rgba(255,255,255,0.06)"; if (status === 0) return stylesTokens.rowEmptyBg;
return stylesTokens.rowDefaultBg;
}; };
const getNameColor = (status) => { const getNameColor = (status) => {
if (status === 1) return "#ffb3b3"; if (status === 1) return stylesTokens.rowNoText;
if (status === 2) return "#baf3c9"; if (status === 2) return stylesTokens.rowOkText;
if (status === 3) return "rgba(233,216,166,0.78)"; if (status === 3) return stylesTokens.rowMaybeText;
return stylesTokens.textMain; return stylesTokens.textMain;
}; };
@@ -43,11 +34,17 @@ export default function SheetSection({
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
if (status === 2) return { color: "#baf3c9", background: "rgba(0,190,80,0.18)" }; if (status === 2) return { color: stylesTokens.badgeOkText, background: stylesTokens.badgeOkBg };
if (status === 1) return { color: "#ffb3b3", background: "rgba(255,35,35,0.18)" }; if (status === 1) return { color: stylesTokens.badgeNoText, background: stylesTokens.badgeNoBg };
if (status === 3) if (status === 3) return { color: stylesTokens.badgeMaybeText, background: stylesTokens.badgeMaybeBg };
return { color: "rgba(233,216,166,0.85)", background: "rgba(140,140,140,0.14)" }; return { color: stylesTokens.badgeEmptyText, background: stylesTokens.badgeEmptyBg };
return { color: "rgba(233,216,166,0.75)", background: "rgba(255,255,255,0.08)" }; };
const getBorderLeft = (status) => {
if (status === 2) return `4px solid ${stylesTokens.rowOkBorder}`;
if (status === 1) return `4px solid ${stylesTokens.rowNoBorder}`;
if (status === 3) return `4px solid ${stylesTokens.rowMaybeBorder}`;
return `4px solid ${stylesTokens.rowEmptyBorder}`;
}; };
return ( return (
@@ -56,7 +53,6 @@ export default function SheetSection({
<div style={{ display: "grid" }}> <div style={{ display: "grid" }}>
{entries.map((e) => { {entries.map((e) => {
// UI "rot" wenn note_tag i/m/s (Backend s wird als s.XX angezeigt)
const isIorMorS = e.note_tag === "i" || e.note_tag === "m" || e.note_tag === "s"; const isIorMorS = e.note_tag === "i" || e.note_tag === "m" || e.note_tag === "s";
const effectiveStatus = e.status === 0 && isIorMorS ? 1 : e.status; const effectiveStatus = e.status === 0 && isIorMorS ? 1 : e.status;
@@ -70,14 +66,7 @@ export default function SheetSection({
...styles.row, ...styles.row,
background: getRowBg(effectiveStatus), background: getRowBg(effectiveStatus),
animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none", animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none",
borderLeft: borderLeft: getBorderLeft(effectiveStatus),
effectiveStatus === 2
? "4px solid rgba(0,190,80,0.55)"
: effectiveStatus === 1
? "4px solid rgba(255,35,35,0.55)"
: effectiveStatus === 3
? "4px solid rgba(233,216,166,0.22)"
: "4px solid rgba(0,0,0,0)",
}} }}
> >
<div <div

View File

@@ -7,44 +7,20 @@ export default function TopBar({
userMenuOpen, userMenuOpen,
setUserMenuOpen, setUserMenuOpen,
openPwModal, openPwModal,
openDesignModal,
doLogout, doLogout,
newGame, newGame,
}) { }) {
return ( return (
<div style={styles.topBar}> <div style={styles.topBar}>
{/* LINKS: nur Rolle */}
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
Notizbogen <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div>
</div>
<div
style={{
fontSize: 12,
opacity: 0.8,
color: stylesTokens.textDim,
}}
>
{me.email}
</div>
</div> </div>
{/* RECHTS: Account + Neues Spiel */} <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "nowrap",
}}
data-user-menu
>
{/* Account Dropdown */}
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button <button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü">
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16 }}>👤</span> <span style={{ fontSize: 16 }}>👤</span>
<span>User</span> <span>User</span>
<span style={{ opacity: 0.7 }}></span> <span style={{ opacity: 0.7 }}></span>
@@ -52,7 +28,6 @@ export default function TopBar({
{userMenuOpen && ( {userMenuOpen && (
<div style={styles.userDropdown}> <div style={styles.userDropdown}>
{/* Email Info */}
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -65,11 +40,14 @@ export default function TopBar({
{me.email} {me.email}
</div> </div>
{/* Actions */}
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen Passwort setzen
</button> </button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
</button>
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button <button
@@ -77,10 +55,7 @@ export default function TopBar({
setUserMenuOpen(false); setUserMenuOpen(false);
doLogout(); doLogout();
}} }}
style={{ style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
...styles.userDropdownItem,
color: "#ffb3b3",
}}
> >
Logout Logout
</button> </button>
@@ -88,7 +63,6 @@ export default function TopBar({
)} )}
</div> </div>
{/* Neues Spiel Button */}
<button onClick={newGame} style={styles.primaryBtn}> <button onClick={newGame} style={styles.primaryBtn}>
New Game New Game
</button> </button>

View File

@@ -0,0 +1,43 @@
// src/components/WinnerBadge.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function WinnerBadge({ winner }) {
const w = (winner || "").trim();
if (!w) return null;
return (
<div style={{ marginTop: 14 }}>
<div
style={{
...styles.card,
padding: 12,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div>
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
</div>
<div
style={{
padding: "8px 12px",
borderRadius: 999,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textGold,
fontWeight: 1000,
whiteSpace: "nowrap",
}}
>
Gewonnen
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function WinnerCard({ value, setValue, onSave }) {
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.cardBody}>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Name des Siegers"
style={{ ...styles.input, flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter") onSave();
}}
/>
<button onClick={onSave} style={styles.primaryBtn} title="Speichern">
Speichern
</button>
</div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim }}>
Wird pro Spiel lokal gespeichert.
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { stylesTokens } from "../theme"; import { stylesTokens } from "../theme";
import { applyTheme } from "../themes";
export function useHpGlobalStyles() { export function useHpGlobalStyles() {
// Google Fonts // Google Fonts
@@ -81,4 +84,9 @@ export function useHpGlobalStyles() {
`; `;
document.head.appendChild(style); document.head.appendChild(style);
}, []); }, []);
// Ensure a theme is applied once (fallback)
useEffect(() => {
applyTheme("default");
}, []);
} }

View File

@@ -49,8 +49,11 @@ export const styles = {
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui', fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.0, letterSpacing: 1.0,
color: stylesTokens.textGold, color: stylesTokens.textGold,
background: "linear-gradient(180deg, rgba(32,32,36,0.92), rgba(14,14,16,0.92))",
borderBottom: `1px solid ${stylesTokens.goldLine}`, // WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
background: `linear-gradient(180deg, ${stylesTokens.headerBgTop}, ${stylesTokens.headerBgBottom})`,
borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase", textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.6)", textShadow: "0 1px 0 rgba(0,0,0,0.6)",
}, },
@@ -418,7 +421,7 @@ export const styles = {
bgMap: { bgMap: {
position: "absolute", position: "absolute",
inset: 0, inset: 0,
backgroundImage: 'url("/bg/marauders-map-blur.jpg")', backgroundImage: stylesTokens.bgImage,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",

View File

@@ -1,11 +1,36 @@
export const stylesTokens = { export const stylesTokens = {
pageBg: "#0b0b0c", pageBg: "var(--hp-pageBg)",
panelBg: "rgba(20, 20, 22, 0.55)", panelBg: "var(--hp-panelBg)",
panelBorder: "rgba(233, 216, 166, 0.14)", panelBorder: "var(--hp-panelBorder)",
textMain: "rgba(245, 239, 220, 0.92)", textMain: "var(--hp-textMain)",
textDim: "rgba(233, 216, 166, 0.70)", textDim: "var(--hp-textDim)",
textGold: "#e9d8a6", textGold: "var(--hp-textGold)",
goldLine: "rgba(233, 216, 166, 0.18)", goldLine: "var(--hp-goldLine)",
// Header
headerBgTop: "var(--hp-headerBgTop)",
headerBgBottom: "var(--hp-headerBgBottom)",
headerBorder: "var(--hp-headerBorder)",
// Rows
rowNoBg: "var(--hp-rowNoBg)",
rowNoText: "var(--hp-rowNoText)",
rowNoBorder: "var(--hp-rowNoBorder)",
rowOkBg: "var(--hp-rowOkBg)",
rowOkText: "var(--hp-rowOkText)",
rowOkBorder: "var(--hp-rowOkBorder)",
rowMaybeBg: "var(--hp-rowMaybeBg)",
rowMaybeText: "var(--hp-rowMaybeText)",
rowMaybeBorder: "var(--hp-rowMaybeBorder)",
rowEmptyBg: "var(--hp-rowEmptyBg)",
rowEmptyText: "var(--hp-rowEmptyText)",
rowEmptyBorder: "var(--hp-rowEmptyBorder)",
// Background
bgImage: "var(--hp-bgImage)",
}; };

View File

@@ -0,0 +1,223 @@
// frontend/src/styles/themes.js
export const THEMES = {
default: {
label: "Standard",
tokens: {
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)",
// Section header (wie TopBar, aber leicht “tiefer”)
headerBgTop: "rgba(32,32,36,0.92)",
headerBgBottom: "rgba(14,14,16,0.92)",
headerBorder: "rgba(233, 216, 166, 0.18)",
// Row colors (fix falsch bleibt rot in ALLEN themes)
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(0, 190, 80, 0.16)",
rowOkText: "#baf3c9",
rowOkBorder: "rgba(0,190,80,0.55)",
rowMaybeBg: "rgba(140, 140, 140, 0.12)",
rowMaybeText: "rgba(233,216,166,0.85)",
rowMaybeBorder: "rgba(233,216,166,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(233,216,166,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/marauders-map-blur.jpg')",
},
},
gryffindor: {
label: "Gryffindor",
tokens: {
pageBg: "#0b0b0c",
panelBg: "rgba(20, 14, 14, 0.58)",
panelBorder: "rgba(255, 190, 120, 0.16)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(255, 210, 170, 0.70)",
textGold: "#ffb86b",
goldLine: "rgba(255, 184, 107, 0.18)",
headerBgTop: "rgba(42,18,18,0.92)",
headerBgBottom: "rgba(18,10,10,0.92)",
headerBorder: "rgba(255, 184, 107, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(255, 184, 107, 0.16)",
rowOkText: "#ffd2a8",
rowOkBorder: "rgba(255,184,107,0.55)",
rowMaybeBg: "rgba(140, 140, 140, 0.12)",
rowMaybeText: "rgba(255,210,170,0.85)",
rowMaybeBorder: "rgba(255,184,107,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(255,210,170,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/gryffindor.png')",
},
},
slytherin: {
label: "Slytherin",
tokens: {
pageBg: "#070a09",
panelBg: "rgba(12, 20, 16, 0.58)",
panelBorder: "rgba(120, 255, 190, 0.12)",
textMain: "rgba(235, 245, 240, 0.92)",
textDim: "rgba(175, 240, 210, 0.70)",
textGold: "#7CFFB6",
goldLine: "rgba(124, 255, 182, 0.18)",
headerBgTop: "rgba(14,28,22,0.92)",
headerBgBottom: "rgba(10,14,12,0.92)",
headerBorder: "rgba(124, 255, 182, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(124, 255, 182, 0.16)",
rowOkText: "rgba(190,255,220,0.92)",
rowOkBorder: "rgba(124,255,182,0.55)",
rowMaybeBg: "rgba(120, 255, 190, 0.10)",
rowMaybeText: "rgba(175,240,210,0.85)",
rowMaybeBorder: "rgba(120,255,190,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(175,240,210,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/slytherin.png')",
},
},
ravenclaw: {
label: "Ravenclaw",
tokens: {
pageBg: "#07080c",
panelBg: "rgba(14, 16, 24, 0.60)",
panelBorder: "rgba(140, 180, 255, 0.14)",
textMain: "rgba(235, 240, 250, 0.92)",
textDim: "rgba(180, 205, 255, 0.72)",
textGold: "#8FB6FF",
goldLine: "rgba(143, 182, 255, 0.18)",
headerBgTop: "rgba(18,22,40,0.92)",
headerBgBottom: "rgba(10,12,20,0.92)",
headerBorder: "rgba(143, 182, 255, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(143, 182, 255, 0.16)",
rowOkText: "rgba(210,230,255,0.92)",
rowOkBorder: "rgba(143,182,255,0.55)",
rowMaybeBg: "rgba(140, 180, 255, 0.10)",
rowMaybeText: "rgba(180,205,255,0.85)",
rowMaybeBorder: "rgba(143,182,255,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(180,205,255,0.78)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/ravenclaw.png')",
},
},
hufflepuff: {
label: "Hufflepuff",
tokens: {
pageBg: "#0b0b0c",
panelBg: "rgba(18, 18, 14, 0.60)",
panelBorder: "rgba(255, 230, 120, 0.16)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(255, 240, 180, 0.70)",
textGold: "#FFE27A",
goldLine: "rgba(255, 226, 122, 0.18)",
headerBgTop: "rgba(34,30,14,0.92)",
headerBgBottom: "rgba(16,14,8,0.92)",
headerBorder: "rgba(255, 226, 122, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(255, 226, 122, 0.16)",
rowOkText: "rgba(255,240,190,0.92)",
rowOkBorder: "rgba(255,226,122,0.55)",
rowMaybeBg: "rgba(255, 226, 122, 0.10)",
rowMaybeText: "rgba(255,240,180,0.85)",
rowMaybeBorder: "rgba(255,226,122,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(255,240,180,0.78)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/hufflepuff.png')",
},
},
};
export const DEFAULT_THEME_KEY = "default";
export function applyTheme(themeKey) {
const t = THEMES[themeKey] || THEMES[DEFAULT_THEME_KEY];
const root = document.documentElement;
for (const [k, v] of Object.entries(t.tokens)) {
root.style.setProperty(`--hp-${k}`, v);
}
}
export function themeStorageKey(email) {
return `hpTheme:${(email || "guest").toLowerCase()}`;
}
export function loadThemeKey(email) {
try {
return localStorage.getItem(themeStorageKey(email)) || DEFAULT_THEME_KEY;
} catch {
return DEFAULT_THEME_KEY;
}
}
export function saveThemeKey(email, key) {
try {
localStorage.setItem(themeStorageKey(email), key);
} catch {}
}

View File

@@ -0,0 +1,28 @@
// frontend/src/utils/winnerStorage.js
function winnerKey(gameId) {
return `winner:${gameId}`;
}
export function getWinnerLS(gameId) {
if (!gameId) return "";
try {
return localStorage.getItem(winnerKey(gameId)) || "";
} catch {
return "";
}
}
export function setWinnerLS(gameId, name) {
if (!gameId) return;
try {
localStorage.setItem(winnerKey(gameId), (name || "").trim());
} catch {}
}
export function clearWinnerLS(gameId) {
if (!gameId) return;
try {
localStorage.removeItem(winnerKey(gameId));
} catch {}
}