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 { cycleTag } from "./utils/cycleTag";
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
import { getWinner, setWinner as saveWinnerLS } from "./utils/winnerStorage";
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles";
import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
import AdminPanel from "./components/AdminPanel";
import LoginPage from "./components/LoginPage";
import TopBar from "./components/TopBar";
@@ -15,6 +19,8 @@ import ChipModal from "./components/ChipModal";
import HelpModal from "./components/HelpModal";
import GamePickerCard from "./components/GamePickerCard";
import SheetSection from "./components/SheetSection";
import DesignModal from "./components/DesignModal";
import WinnerCard from "./components/WinnerCard";
export default function App() {
useHpGlobalStyles();
@@ -31,6 +37,9 @@ export default function App() {
const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null);
// Winner (per game)
const [winnerName, setWinnerName] = useState("");
// Modals
const [helpOpen, setHelpOpen] = useState(false);
@@ -45,11 +54,20 @@ export default function App() {
const [pwMsg, setPwMsg] = useState("");
const [pwSaving, setPwSaving] = useState(false);
// Theme
const [designOpen, setDesignOpen] = useState(false);
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
// ===== Data loaders =====
const load = async () => {
const m = await api("/auth/me");
setMe(m);
// Theme pro User laden & anwenden
const tk = loadThemeKey(m?.email);
setThemeKey(tk);
applyTheme(tk);
const gs = await api("/games");
setGames(gs);
@@ -62,8 +80,6 @@ export default function App() {
setSheet(sh);
};
// ===== Effects =====
// Dropdown outside click
useEffect(() => {
const onDown = (e) => {
@@ -92,9 +108,9 @@ export default function App() {
if (!gameId) return;
try {
await reloadSheet();
} catch {
// ignore
}
} catch {}
// Winner pro Game aus LS laden
setWinnerName(getWinner(gameId));
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]);
@@ -114,6 +130,7 @@ export default function App() {
setGames([]);
setGameId(null);
setSheet(null);
setWinnerName("");
};
// ===== 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 =====
const newGame = async () => {
const g = await api("/games", {
@@ -164,6 +193,12 @@ export default function App() {
setGameId(g.id);
};
// ===== Winner actions =====
const saveWinner = () => {
if (!gameId) return;
saveWinnerLS(gameId, winnerName.trim());
};
// ===== Sheet actions =====
const cycleStatus = async (entry) => {
let next = 0;
@@ -185,14 +220,12 @@ export default function App() {
const toggleTag = async (entry) => {
const next = cycleTag(entry.note_tag);
// going to "s" -> open chip modal, don't write backend yet
if (next === "s") {
setChipEntry(entry);
setChipOpen(true);
return;
}
// s -> — : clear local chip
if (next === null) clearChipLS(gameId, entry.entry_id);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
@@ -210,11 +243,9 @@ export default function App() {
setChipOpen(false);
setChipEntry(null);
// frontend-only save
setChipLS(gameId, entry.entry_id, chip);
try {
// backend only gets "s"
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ note_tag: "s" }),
@@ -253,7 +284,7 @@ export default function App() {
const chip = getChipLS(gameId, entry.entry_id);
return chip ? `s.${chip}` : "s";
}
return t; // i oder m
return t;
};
// ===== Login page =====
@@ -291,6 +322,7 @@ export default function App() {
userMenuOpen={userMenuOpen}
setUserMenuOpen={setUserMenuOpen}
openPwModal={openPwModal}
openDesignModal={openDesignModal}
doLogout={doLogout}
newGame={newGame}
/>
@@ -320,6 +352,9 @@ export default function App() {
))}
</div>
{/* Sieger ganz unten */}
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} />
<div style={{ height: 24 }} />
</div>
@@ -335,11 +370,17 @@ export default function App() {
savePassword={savePassword}
/>
<ChipModal
chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip}
<DesignModal
open={designOpen}
onClose={() => setDesignOpen(false)}
themeKey={themeKey}
onSelect={(k) => {
selectTheme(k);
setDesignOpen(false);
}}
/>
<ChipModal chipOpen={chipOpen} closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} />
</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,
setUserMenuOpen,
openPwModal,
openDesignModal,
doLogout,
newGame,
}) {
return (
<div style={styles.topBar}>
{/* LINKS: nur Rolle */}
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
Notizbogen
</div>
<div
style={{
fontSize: 12,
opacity: 0.8,
color: stylesTokens.textDim,
}}
>
{me.email}
</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div>
</div>
{/* RECHTS: Account + Neues Spiel */}
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "nowrap",
}}
data-user-menu
>
{/* Account Dropdown */}
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}>
<button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü">
<span style={{ fontSize: 16 }}>👤</span>
<span>User</span>
<span style={{ opacity: 0.7 }}></span>
@@ -52,7 +28,6 @@ export default function TopBar({
{userMenuOpen && (
<div style={styles.userDropdown}>
{/* Email Info */}
<div
style={{
padding: "10px 12px",
@@ -65,11 +40,14 @@ export default function TopBar({
{me.email}
</div>
{/* Actions */}
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
</button>
<div style={styles.userDropdownDivider} />
<button
@@ -77,10 +55,7 @@ export default function TopBar({
setUserMenuOpen(false);
doLogout();
}}
style={{
...styles.userDropdownItem,
color: "#ffb3b3",
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
Logout
</button>
@@ -88,7 +63,6 @@ export default function TopBar({
)}
</div>
{/* Neues Spiel Button */}
<button onClick={newGame} style={styles.primaryBtn}>
New Game
</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 { stylesTokens } from "../theme";
import { applyTheme } from "../themes";
export function useHpGlobalStyles() {
// Google Fonts
@@ -81,4 +84,9 @@ export function useHpGlobalStyles() {
`;
document.head.appendChild(style);
}, []);
// Ensure a theme is applied once (fallback)
useEffect(() => {
applyTheme("default");
}, []);
}

View File

@@ -1,11 +1,11 @@
export const stylesTokens = {
pageBg: "#0b0b0c",
panelBg: "rgba(20, 20, 22, 0.55)",
panelBorder: "rgba(233, 216, 166, 0.14)",
pageBg: "var(--hp-pageBg)",
panelBg: "var(--hp-panelBg)",
panelBorder: "var(--hp-panelBorder)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(233, 216, 166, 0.70)",
textGold: "#e9d8a6",
textMain: "var(--hp-textMain)",
textDim: "var(--hp-textDim)",
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 {}
}