Merge dev branch into main brunch #1
@@ -1,36 +1,42 @@
|
||||
// src/App.jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "./api/client";
|
||||
import { cycleTag } from "./utils/cycleTag";
|
||||
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
|
||||
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
|
||||
import { styles } from "./styles/styles";
|
||||
import { stylesTokens } from "./styles/theme";
|
||||
|
||||
import AdminPanel from "./components/AdminPanel";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import TopBar from "./components/TopBar";
|
||||
import PasswordModal from "./components/PasswordModal";
|
||||
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() {
|
||||
useHpGlobalStyles();
|
||||
|
||||
// Auth/Login UI state
|
||||
const [me, setMe] = useState(null);
|
||||
const [loginEmail, setLoginEmail] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
|
||||
// Game/Sheet state
|
||||
const [games, setGames] = useState([]);
|
||||
const [gameId, setGameId] = useState(null);
|
||||
const [sheet, setSheet] = useState(null);
|
||||
const [pulseId, setPulseId] = useState(null);
|
||||
|
||||
// Modals
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const [chipOpen, setChipOpen] = useState(false);
|
||||
const [chipEntry, setChipEntry] = useState(null);
|
||||
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
@@ -39,6 +45,7 @@ export default function App() {
|
||||
const [pwMsg, setPwMsg] = useState("");
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
|
||||
// ===== Data loaders =====
|
||||
const load = async () => {
|
||||
const m = await api("/auth/me");
|
||||
setMe(m);
|
||||
@@ -49,6 +56,14 @@ export default function App() {
|
||||
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
|
||||
useEffect(() => {
|
||||
const onDown = (e) => {
|
||||
@@ -59,25 +74,32 @@ export default function App() {
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [userMenuOpen]);
|
||||
|
||||
// initial load (try session)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch {}
|
||||
} catch {
|
||||
// not logged in
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// load sheet when game changes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!gameId) return;
|
||||
try {
|
||||
const sh = await api(`/games/${gameId}/sheet`);
|
||||
setSheet(sh);
|
||||
} catch {}
|
||||
await reloadSheet();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameId]);
|
||||
|
||||
// ===== Auth actions =====
|
||||
const doLogin = async () => {
|
||||
await api("/auth/login", {
|
||||
method: "POST",
|
||||
@@ -94,7 +116,7 @@ export default function App() {
|
||||
setSheet(null);
|
||||
};
|
||||
|
||||
// Password change
|
||||
// ===== Password change =====
|
||||
const openPwModal = () => {
|
||||
setPwMsg("");
|
||||
setPw1("");
|
||||
@@ -112,6 +134,7 @@ export default function App() {
|
||||
|
||||
const savePassword = async () => {
|
||||
setPwMsg("");
|
||||
|
||||
if (!pw1 || pw1.length < 8) return setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
|
||||
if (pw1 !== pw2) return setPwMsg("❌ Passwörter stimmen nicht überein.");
|
||||
|
||||
@@ -130,6 +153,7 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Game actions =====
|
||||
const newGame = async () => {
|
||||
const g = await api("/games", {
|
||||
method: "POST",
|
||||
@@ -140,12 +164,7 @@ export default function App() {
|
||||
setGameId(g.id);
|
||||
};
|
||||
|
||||
const reloadSheet = async () => {
|
||||
if (!gameId) return;
|
||||
const sh = await api(`/games/${gameId}/sheet`);
|
||||
setSheet(sh);
|
||||
};
|
||||
|
||||
// ===== Sheet actions =====
|
||||
const cycleStatus = async (entry) => {
|
||||
let next = 0;
|
||||
if (entry.status === 0) next = 2;
|
||||
@@ -166,18 +185,21 @@ 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}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: next }),
|
||||
});
|
||||
|
||||
await reloadSheet();
|
||||
};
|
||||
|
||||
@@ -188,9 +210,11 @@ 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" }),
|
||||
@@ -229,7 +253,7 @@ export default function App() {
|
||||
const chip = getChipLS(gameId, entry.entry_id);
|
||||
return chip ? `s.${chip}` : "s";
|
||||
}
|
||||
return t;
|
||||
return t; // i oder m
|
||||
};
|
||||
|
||||
// ===== Login page =====
|
||||
@@ -273,8 +297,30 @@ export default function App() {
|
||||
|
||||
{me.role === "admin" && <AdminPanel />}
|
||||
|
||||
{/* hier bleiben vorerst: Spiel selector + Help + Sections
|
||||
-> kannst du als nächsten Schritt in Komponenten auslagern */}
|
||||
<GamePickerCard
|
||||
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>
|
||||
|
||||
<PasswordModal
|
||||
@@ -289,7 +335,11 @@ export default function App() {
|
||||
savePassword={savePassword}
|
||||
/>
|
||||
|
||||
<ChipModal chipOpen={chipOpen} closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} />
|
||||
<ChipModal
|
||||
chipOpen={chipOpen}
|
||||
closeChipModalToDash={closeChipModalToDash}
|
||||
chooseChip={chooseChip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
36
frontend/src/components/GamePickerCard.jsx
Normal file
36
frontend/src/components/GamePickerCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/HelpModal.jsx
Normal file
134
frontend/src/components/HelpModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/SheetSection.jsx
Normal file
121
frontend/src/components/SheetSection.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user