dev #3
@@ -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