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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
57
frontend/src/components/DesignModal.jsx
Normal file
57
frontend/src/components/DesignModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
33
frontend/src/components/WinnerCard.jsx
Normal file
33
frontend/src/components/WinnerCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}, []);
|
||||
}
|
||||
@@ -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)",
|
||||
};
|
||||
106
frontend/src/styles/themes.js
Normal file
106
frontend/src/styles/themes.js
Normal 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 {}
|
||||
}
|
||||
20
frontend/src/utils/winnerStorage.js
Normal file
20
frontend/src/utils/winnerStorage.js
Normal 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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user