From a08b74ff7a3b365933460ef5fa89a45e56d947af Mon Sep 17 00:00:00 2001 From: nessi Date: Fri, 6 Feb 2026 09:15:51 +0100 Subject: [PATCH] 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. --- frontend/src/App.jsx | 69 +++++++++--- frontend/src/components/DesignModal.jsx | 57 ++++++++++ frontend/src/components/TopBar.jsx | 46 ++------ frontend/src/components/WinnerCard.jsx | 33 ++++++ .../src/styles/hooks/useHpGlobalStyles.js | 8 ++ frontend/src/styles/theme.js | 16 +-- frontend/src/styles/themes.js | 106 ++++++++++++++++++ frontend/src/utils/winnerStorage.js | 20 ++++ 8 files changed, 297 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/DesignModal.jsx create mode 100644 frontend/src/components/WinnerCard.jsx create mode 100644 frontend/src/styles/themes.js create mode 100644 frontend/src/utils/winnerStorage.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4f56f65..9581f76 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { ))} + {/* Sieger ganz unten */} + +
@@ -335,11 +370,17 @@ export default function App() { savePassword={savePassword} /> - setDesignOpen(false)} + themeKey={themeKey} + onSelect={(k) => { + selectTheme(k); + setDesignOpen(false); + }} /> + + ); } diff --git a/frontend/src/components/DesignModal.jsx b/frontend/src/components/DesignModal.jsx new file mode 100644 index 0000000..84a2ca9 --- /dev/null +++ b/frontend/src/components/DesignModal.jsx @@ -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 ( +
+
e.stopPropagation()}> +
+
Design ändern
+ +
+ +
+ Wähle dein Theme: +
+ +
+ {themeEntries.map(([key, t]) => { + const active = key === themeKey; + + return ( + + ); + })} +
+ +
+ Hinweis: Das Design wird pro User gespeichert (Email). +
+
+
+ ); +} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index dfdbbd3..7a74545 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -7,44 +7,20 @@ export default function TopBar({ userMenuOpen, setUserMenuOpen, openPwModal, + openDesignModal, doLogout, newGame, }) { return (
- {/* LINKS: nur Rolle */}
-
- Notizbogen -
-
- {me.email} -
+
Notizbogen
+
{me.email}
- {/* RECHTS: Account + Neues Spiel */} -
- {/* Account Dropdown */} +
- + +
@@ -88,7 +63,6 @@ export default function TopBar({ )}
- {/* Neues Spiel Button */} diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx new file mode 100644 index 0000000..e365d0b --- /dev/null +++ b/frontend/src/components/WinnerCard.jsx @@ -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 ( +
+
+
Sieger
+ +
+ setValue(e.target.value)} + placeholder="Name des Siegers" + style={{ ...styles.input, flex: 1 }} + onKeyDown={(e) => { + if (e.key === "Enter") onSave(); + }} + /> + + +
+ +
+ Wird pro Spiel lokal gespeichert. +
+
+
+ ); +} diff --git a/frontend/src/styles/hooks/useHpGlobalStyles.js b/frontend/src/styles/hooks/useHpGlobalStyles.js index 85edad3..90fa927 100644 --- a/frontend/src/styles/hooks/useHpGlobalStyles.js +++ b/frontend/src/styles/hooks/useHpGlobalStyles.js @@ -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"); + }, []); } \ No newline at end of file diff --git a/frontend/src/styles/theme.js b/frontend/src/styles/theme.js index 60c18ad..50dd625 100644 --- a/frontend/src/styles/theme.js +++ b/frontend/src/styles/theme.js @@ -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)", - }; \ No newline at end of file + goldLine: "var(--hp-goldLine)", + }; \ No newline at end of file diff --git a/frontend/src/styles/themes.js b/frontend/src/styles/themes.js new file mode 100644 index 0000000..2350018 --- /dev/null +++ b/frontend/src/styles/themes.js @@ -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 {} +} diff --git a/frontend/src/utils/winnerStorage.js b/frontend/src/utils/winnerStorage.js new file mode 100644 index 0000000..7b65297 --- /dev/null +++ b/frontend/src/utils/winnerStorage.js @@ -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 {} + } + \ No newline at end of file