Merge dev branch into main brunch #1

Merged
nessi merged 7 commits from dev into main 2026-02-05 09:04:24 +00:00
4 changed files with 360 additions and 19 deletions
Showing only changes of commit 3b628b6c57 - Show all commits

View File

@@ -1,36 +1,42 @@
// src/App.jsx
import React, { useEffect, useState } from "react"; 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 { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
import { styles } from "./styles/styles"; import { styles } from "./styles/styles";
import { stylesTokens } from "./styles/theme";
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";
import PasswordModal from "./components/PasswordModal"; import PasswordModal from "./components/PasswordModal";
import ChipModal from "./components/ChipModal"; import ChipModal from "./components/ChipModal";
// HelpModal + SheetSection würdest du analog auslagern import HelpModal from "./components/HelpModal";
import GamePickerCard from "./components/GamePickerCard";
import SheetSection from "./components/SheetSection";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
// Auth/Login UI state
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
const [loginEmail, setLoginEmail] = useState(""); const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
// Game/Sheet state
const [games, setGames] = useState([]); const [games, setGames] = useState([]);
const [gameId, setGameId] = useState(null); const [gameId, setGameId] = useState(null);
const [sheet, setSheet] = useState(null); const [sheet, setSheet] = useState(null);
const [pulseId, setPulseId] = useState(null); const [pulseId, setPulseId] = useState(null);
// Modals
const [helpOpen, setHelpOpen] = useState(false);
const [chipOpen, setChipOpen] = useState(false); const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null); const [chipEntry, setChipEntry] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false);
@@ -39,6 +45,7 @@ export default function App() {
const [pwMsg, setPwMsg] = useState(""); const [pwMsg, setPwMsg] = useState("");
const [pwSaving, setPwSaving] = useState(false); const [pwSaving, setPwSaving] = useState(false);
// ===== Data loaders =====
const load = async () => { const load = async () => {
const m = await api("/auth/me"); const m = await api("/auth/me");
setMe(m); setMe(m);
@@ -49,6 +56,14 @@ export default function App() {
if (gs[0] && !gameId) setGameId(gs[0].id); if (gs[0] && !gameId) setGameId(gs[0].id);
}; };
const reloadSheet = async () => {
if (!gameId) return;
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
};
// ===== Effects =====
// Dropdown outside click // Dropdown outside click
useEffect(() => { useEffect(() => {
const onDown = (e) => { const onDown = (e) => {
@@ -59,25 +74,32 @@ export default function App() {
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [userMenuOpen]); }, [userMenuOpen]);
// initial load (try session)
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
await load(); await load();
} catch {} } catch {
// not logged in
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// load sheet when game changes
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!gameId) return; if (!gameId) return;
try { try {
const sh = await api(`/games/${gameId}/sheet`); await reloadSheet();
setSheet(sh); } catch {
} catch {} // ignore
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]); }, [gameId]);
// ===== Auth actions =====
const doLogin = async () => { const doLogin = async () => {
await api("/auth/login", { await api("/auth/login", {
method: "POST", method: "POST",
@@ -94,7 +116,7 @@ export default function App() {
setSheet(null); setSheet(null);
}; };
// Password change // ===== Password change =====
const openPwModal = () => { const openPwModal = () => {
setPwMsg(""); setPwMsg("");
setPw1(""); setPw1("");
@@ -112,6 +134,7 @@ export default function App() {
const savePassword = async () => { const savePassword = async () => {
setPwMsg(""); setPwMsg("");
if (!pw1 || pw1.length < 8) return setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben."); if (!pw1 || pw1.length < 8) return setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
if (pw1 !== pw2) return setPwMsg("❌ Passwörter stimmen nicht überein."); if (pw1 !== pw2) return setPwMsg("❌ Passwörter stimmen nicht überein.");
@@ -130,6 +153,7 @@ export default function App() {
} }
}; };
// ===== Game actions =====
const newGame = async () => { const newGame = async () => {
const g = await api("/games", { const g = await api("/games", {
method: "POST", method: "POST",
@@ -140,12 +164,7 @@ export default function App() {
setGameId(g.id); setGameId(g.id);
}; };
const reloadSheet = async () => { // ===== Sheet actions =====
if (!gameId) return;
const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh);
};
const cycleStatus = async (entry) => { const cycleStatus = async (entry) => {
let next = 0; let next = 0;
if (entry.status === 0) next = 2; if (entry.status === 0) next = 2;
@@ -166,18 +185,21 @@ 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}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ note_tag: next }), body: JSON.stringify({ note_tag: next }),
}); });
await reloadSheet(); await reloadSheet();
}; };
@@ -188,9 +210,11 @@ 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" }),
@@ -229,7 +253,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; return t; // i oder m
}; };
// ===== Login page ===== // ===== Login page =====
@@ -273,8 +297,30 @@ export default function App() {
{me.role === "admin" && <AdminPanel />} {me.role === "admin" && <AdminPanel />}
{/* hier bleiben vorerst: Spiel selector + Help + Sections <GamePickerCard
-> kannst du als nächsten Schritt in Komponenten auslagern */} games={games}
gameId={gameId}
setGameId={setGameId}
onOpenHelp={() => setHelpOpen(true)}
/>
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => (
<SheetSection
key={sec.key}
title={sec.title}
entries={sec.entries}
pulseId={pulseId}
onCycleStatus={cycleStatus}
onToggleTag={toggleTag}
displayTag={displayTag}
/>
))}
</div>
<div style={{ height: 24 }} />
</div> </div>
<PasswordModal <PasswordModal
@@ -289,7 +335,11 @@ export default function App() {
savePassword={savePassword} savePassword={savePassword}
/> />
<ChipModal chipOpen={chipOpen} closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} /> <ChipModal
chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,36 @@
// src/components/GamePickerCard.jsx
import React from "react";
import { styles } from "../styles/styles";
export default function GamePickerCard({
games,
gameId,
setGameId,
onOpenHelp,
}) {
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div>
<div style={styles.cardBody}>
<select
value={gameId || ""}
onChange={(e) => setGameId(e.target.value)}
style={{ ...styles.input, flex: 1 }}
>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe">
Hilfe
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
// src/components/HelpModal.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function HelpModal({ open, onClose }) {
if (!open) return null;
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 }}>Hilfe</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={styles.helpBody}>
<div style={styles.helpSectionTitle}>1) Namen anklicken (Status)</div>
<div style={styles.helpText}>
Tippe auf einen Namen, um den Status zu wechseln. Reihenfolge:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,35,35,0.18)",
color: "#ffb3b3",
}}
>
</span>
<div>
<b>Rot</b> = ausgeschlossen / fix falsch
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(140,140,140,0.14)",
color: "rgba(233,216,166,0.85)",
}}
>
?
</span>
<div>
<b>Grau</b> = unsicher / vielleicht
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,255,255,0.08)",
color: "rgba(233,216,166,0.75)",
}}
>
</span>
<div>
<b>Leer</b> = unknown / noch nicht bewertet
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notiz)</div>
<div style={styles.helpText}>
Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>i</span>
<div>
<b>i</b> = Ich habe diese Geheimkarte
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>m</span>
<div>
<b>m</b> = Geheimkarte aus dem mittleren Deck
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>s</span>
<div>
<b>s</b> = Ein anderer Spieler hat diese Karte (Chip Auswahl)
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b></b> = keine Notiz
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpText}>
Tipp: Jeder Spieler sieht nur seine eigenen Notizen andere Spieler können nicht in
deinen Zettel schauen.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
// src/components/SheetSection.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
/**
* props:
* - title: string
* - entries: array
* - pulseId: number | null
* - onCycleStatus(entry): fn
* - onToggleTag(entry): fn
* - displayTag(entry): string
*/
export default function SheetSection({
title,
entries,
pulseId,
onCycleStatus,
onToggleTag,
displayTag,
}) {
// --- helpers (lokal, weil sie rein UI sind) ---
const getRowBg = (status) => {
if (status === 1) return "rgba(255, 35, 35, 0.16)";
if (status === 2) return "rgba(0, 190, 80, 0.16)";
if (status === 3) return "rgba(140, 140, 140, 0.12)";
return "rgba(255,255,255,0.06)";
};
const getNameColor = (status) => {
if (status === 1) return "#ffb3b3";
if (status === 2) return "#baf3c9";
if (status === 3) return "rgba(233,216,166,0.78)";
return stylesTokens.textMain;
};
const getStatusSymbol = (status) => {
if (status === 2) return "✓";
if (status === 1) return "✕";
if (status === 3) return "?";
return "";
};
const getStatusBadge = (status) => {
if (status === 2) return { color: "#baf3c9", background: "rgba(0,190,80,0.18)" };
if (status === 1) return { color: "#ffb3b3", background: "rgba(255,35,35,0.18)" };
if (status === 3)
return { color: "rgba(233,216,166,0.85)", background: "rgba(140,140,140,0.14)" };
return { color: "rgba(233,216,166,0.75)", background: "rgba(255,255,255,0.08)" };
};
return (
<div style={styles.card}>
<div style={styles.sectionHeader}>{title}</div>
<div style={{ display: "grid" }}>
{entries.map((e) => {
// UI "rot" wenn note_tag i/m/s (Backend s wird als s.XX angezeigt)
const isIorMorS = e.note_tag === "i" || e.note_tag === "m" || e.note_tag === "s";
const effectiveStatus = e.status === 0 && isIorMorS ? 1 : e.status;
const badge = getStatusBadge(effectiveStatus);
return (
<div
key={e.entry_id}
className="hp-row"
style={{
...styles.row,
background: getRowBg(effectiveStatus),
animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none",
borderLeft:
effectiveStatus === 2
? "4px solid rgba(0,190,80,0.55)"
: effectiveStatus === 1
? "4px solid rgba(255,35,35,0.55)"
: effectiveStatus === 3
? "4px solid rgba(233,216,166,0.22)"
: "4px solid rgba(0,0,0,0)",
}}
>
<div
onClick={() => onCycleStatus(e)}
style={{
...styles.name,
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
color: getNameColor(effectiveStatus),
opacity: effectiveStatus === 1 ? 0.8 : 1,
}}
title="Klick: Grün → Rot → Grau → Leer"
>
{e.label}
</div>
<div style={styles.statusCell}>
<span
style={{
...styles.statusBadge,
color: badge.color,
background: badge.background,
}}
>
{getStatusSymbol(effectiveStatus)}
</span>
</div>
<button
onClick={() => onToggleTag(e)}
style={styles.tagBtn}
title="— → i → m → s.(Chip) → —"
>
{displayTag(e)}
</button>
</div>
);
})}
</div>
</div>
);
}