Add theme customization and winner management features

Introduced a theme selection feature, allowing users to customize the application's appearance, with themes stored per user. Added functionality to manage and store the game's winner locally. These changes improve user experience and personalization.
This commit is contained in:
2026-02-06 09:15:51 +01:00
parent 1db91c6c88
commit a08b74ff7a
8 changed files with 297 additions and 58 deletions

View File

@@ -4,9 +4,13 @@ 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 { getWinner, setWinner as saveWinnerLS } 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 +19,8 @@ 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";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
@@ -31,6 +37,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 +54,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);
@@ -62,8 +80,6 @@ export default function App() {
setSheet(sh); setSheet(sh);
}; };
// ===== Effects =====
// Dropdown outside click // Dropdown outside click
useEffect(() => { useEffect(() => {
const onDown = (e) => { const onDown = (e) => {
@@ -92,9 +108,9 @@ export default function App() {
if (!gameId) return; if (!gameId) return;
try { try {
await reloadSheet(); await reloadSheet();
} catch { } catch {}
// ignore // Winner pro Game aus LS laden
} setWinnerName(getWinner(gameId));
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
@@ -114,6 +130,7 @@ export default function App() {
setGames([]); setGames([]);
setGameId(null); setGameId(null);
setSheet(null); setSheet(null);
setWinnerName("");
}; };
// ===== Password change ===== // ===== Password change =====
@@ -153,6 +170,18 @@ 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", {
@@ -164,6 +193,12 @@ export default function App() {
setGameId(g.id); setGameId(g.id);
}; };
// ===== Winner actions =====
const saveWinner = () => {
if (!gameId) return;
saveWinnerLS(gameId, winnerName.trim());
};
// ===== Sheet actions ===== // ===== Sheet actions =====
const cycleStatus = async (entry) => { const cycleStatus = async (entry) => {
let next = 0; let next = 0;
@@ -185,14 +220,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 +243,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 +284,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 +322,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}
/> />
@@ -320,6 +352,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,11 +370,17 @@ export default function App() {
savePassword={savePassword} savePassword={savePassword}
/> />
<ChipModal <DesignModal
chipOpen={chipOpen} open={designOpen}
closeChipModalToDash={closeChipModalToDash} onClose={() => setDesignOpen(false)}
chooseChip={chooseChip} themeKey={themeKey}
onSelect={(k) => {
selectTheme(k);
setDesignOpen(false);
}}
/> />
<ChipModal chipOpen={chipOpen} closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} />
</div> </div>
); );
} }

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

@@ -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,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

@@ -1,11 +1,11 @@
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)",
}; };

View File

@@ -0,0 +1,106 @@
// 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)",
},
},
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)",
},
},
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)",
},
},
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)",
},
},
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)",
},
},
};
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,20 @@
function winnerKey(gameId) {
return `winner:${gameId}`;
}
export function getWinner(gameId) {
if (!gameId) return "";
try {
return localStorage.getItem(winnerKey(gameId)) || "";
} catch {
return "";
}
}
export function setWinner(gameId, name) {
if (!gameId) return;
try {
localStorage.setItem(winnerKey(gameId), name || "");
} catch {}
}