Compare commits

..

26 Commits

Author SHA1 Message Date
a3216950c8 Update TopBar UI for improved structure and clarity
Refactored the TopBar component to enhance readability and organization. Added labeled sections for user account, role display, and the "Neues Spiel" button. Adjusted styles and improved spacing for better UI consistency.
2026-02-04 09:06:17 +01:00
e4c1a57f0f Update button text in TopBar component
Revised the button label from "Neues Spiel" to "✦ Neues Spiel" to enhance visual appeal and draw user attention. This minor UI tweak improves the user experience.
2026-02-04 09:02:50 +01:00
3b628b6c57 Refactor App structure and add modular components
Split GamePickerCard, HelpModal, and SheetSection into separate components for better modularity and clarity. Refactored App.jsx to utilize these new components, restructured state variables, and organized functions for improved readability. Enhanced code comments for easier maintenance.
2026-02-04 08:58:00 +01:00
1afb060bbc Refactor app by modularizing components and extracting utilities.
The changes split large features into smaller, reusable components like `AdminPanel`, `LoginPage`, `TopBar`, `PasswordModal`, and `ChipModal`. Utility functions such as `cycleTag` and `chipStorage` were extracted for better organization. This improves the code's readability, maintainability, and scalability.
2026-02-04 08:49:34 +01:00
62eb7e6e58 Update user menu display for cleaner interface
Replaced the email with a generic "Account" label in the user menu for better design consistency and to avoid truncation issues. Adjusted the dropdown max width to 180px for improved alignment with the new layout.
2026-02-03 20:27:21 +01:00
7036f29481 Add password change functionality to user settings
Implemented a secure password change endpoint in the backend with validation. Enhanced the frontend to include a modal for updating the user's password, ensuring real-time input validation and user feedback.
2026-02-03 20:24:20 +01:00
bf37850e79 Improve chip selection and modal closing behavior
Updated the chip handling logic to ensure a smoother user experience by immediately closing modals in the frontend before performing asynchronous operations. Enhanced error handling and streamlined tag display logic for clarity and consistency.
2026-02-03 20:15:46 +01:00
a9dbbd65a4 Update status logic to include "m" in note_tag checks
Previously, only "i" and "s" were considered in the note_tag check for determining effectiveStatus. This update adds "m" to the condition, ensuring accurate status handling for entries with note_tag "m".
2026-02-03 20:09:45 +01:00
ca15266fb9 Add local storage handling for chip selection in frontend
Introduced functions to manage chip data in local storage, ensuring smoother handling of chip selections without backend dependency. Adjusted UI logic to display chip tags dynamically and modified "s" state handling to integrate local storage data seamlessly.
2026-02-03 18:46:33 +01:00
e2b97b69e9 Refactor tag handling and remove unused functions
Consolidated tag cycling logic into a single function, simplifying and clarifying its purpose. Removed redundant functions related to tag parsing and storage. Updated UI elements for better readability and consistency.
2026-02-03 16:10:32 +01:00
59f477c343 Rename CHIP_LIST to CHIP_OPTIONS for clarity
The constant name was updated to better reflect its purpose as a list of selectable options. This improves code readability and maintainability.
2026-02-03 15:53:40 +01:00
7be21969e7 Add cycleTag function for tag value transitions
Implemented the cycleTag function to handle transitions between tag states ("i", "m", "s") and reset for special cases starting with "s.". This prepares the application to support dynamic tag cycling functionality.
2026-02-03 15:49:09 +01:00
db35a7b0c9 Refactor chip state variable names for consistency
Renamed state variables `chipPickOpen` and `chipPickEntry` to `chipOpen` and `chipEntry`. This change improves naming consistency across the component, simplifying readability and maintenance.
2026-02-03 15:39:10 +01:00
d2e2286627 Refactor chip selection logic and UI improvements
Unified chip modal functionality by consolidating state variables and methods. Enhanced UI consistency for effective statuses and streamlined code for readability and maintainability. Updated styles for better visual appeal and clarity.
2026-02-03 15:35:55 +01:00
b8fc47e881 Update text style for additional conditions
Adjusted text decoration and opacity to apply when certain tags ("i" or "s") are set. This ensures better visual feedback for the specified conditions in the application interface.
2026-02-03 15:22:18 +01:00
2ec7c63119 Refactor tag handling and add chip localStorage support
Refactored tag handling logic by introducing helper functions to improve clarity and maintainability. Added localStorage support for storing and retrieving chip values associated with entries, ensuring smoother transitions and proper state management across sessions. Simplified backend interactions for the "s" tag and improved display logic for tags with chips.
2026-02-03 15:13:21 +01:00
6a5ff44135 Add chip selection feature for "s" tags
Implemented a chip selection modal that appears when cycling to the "s" tag. Users can now assign specific chips (e.g., "s.AL") to entries, improving interactivity and flexibility in tagging.
2026-02-03 15:00:31 +01:00
3546500d9e Refactor styles to use central theme tokens for consistency.
Replaced hardcoded colors and styles with reusable `stylesTokens` for improved maintainability and uniformity across the application. Simplified inline styles, cleaned up redundancies, and enhanced readability while adhering to the new dark and gold theme.
2026-02-03 13:52:43 +01:00
6cf7bf506a Add PWA support using vite-plugin-pwa
Integrated the vite-plugin-pwa to enable Progressive Web App functionality. Added service worker registration, manifest configuration, and necessary assets like icons and favicon. This enhances offline capabilities and user experience.
2026-02-03 13:35:52 +01:00
52e1d5f0f8 Update marauders-map-blur.jpg in public assets
Replaced the previous version of the marauders-map-blur.jpg file with an updated one. This ensures the latest version is available in the frontend public assets directory.
2026-02-03 13:18:39 +01:00
e6267986db Update styling for App background elements
Removed unused `bgInkVignette` styles and adjusted `zIndex` for better layering control. Updated background image URL for `bgMap` to ensure proper path resolution. These changes improve overall code clarity and maintainability.
2026-02-03 13:16:35 +01:00
e6d32bc151 Remove redundant background style settings.
The background color settings for `document.body` and `document.documentElement` were removed as they are not needed. This simplifies the global styling without affecting functionality.
2026-02-03 13:11:55 +01:00
9fd8934439 Remove unused bgInkVignette styling and references.
This cleanup removes the bgInkVignette element and its associated styles from the codebase. These changes simplify the component structure and improve maintainability by eliminating unnecessary code.
2026-02-03 13:10:10 +01:00
f65a2b30ee Update comments for background styling configuration
Replaced a single-line comment with a block comment in the background styling configuration. No functional changes were made; this is purely for better readability and maintainability.
2026-02-03 13:07:43 +01:00
def1fb9545 Update background image for the map in App.jsx
Replaced the old background image with a new blurred version to improve visual aesthetics. The updated image is located in the '/public/bg/' directory.
2026-02-03 13:04:36 +01:00
1645de206f Comment out unused CSS styles
Unused CSS styles related to hover, tap highlight, and button states in the App component have been commented out to clean up the file. This change helps to improve code readability and reduce clutter while retaining the styles for potential future use.
2026-02-03 13:03:33 +01:00
23 changed files with 1592 additions and 1069 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User from ..models import User
from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -30,4 +30,23 @@ def me(req: Request, db: Session = Depends(get_db)):
if not user: if not user:
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role} return {"id": user.id, "email": user.email, "role": user.role}
@router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
password = data.get("password") or ""
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
user.password_hash = hash_password(password)
db.add(user)
db.commit()
return {"ok": True}

View File

@@ -13,6 +13,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8" "vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.0"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import { API_BASE } from "../constants";
export async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function AdminPanel() {
const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState("");
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => {
loadUsers().catch(() => {});
}, []);
const resetForm = () => {
setEmail("");
setPassword("");
setRole("user");
};
const createUser = async () => {
setMsg("");
try {
await api("/admin/users", {
method: "POST",
body: JSON.stringify({ email, password, role }),
});
setMsg("✅ User erstellt.");
await loadUsers();
resetForm();
setOpen(false);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
}
};
const closeModal = () => {
setOpen(false);
setMsg("");
};
return (
<div style={styles.adminWrap}>
<div style={styles.adminTop}>
<div style={styles.adminTitle}>Admin Dashboard</div>
<button onClick={() => setOpen(true)} style={styles.primaryBtn}>
+ User anlegen
</button>
</div>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div key={u.id} style={styles.userRow}>
<div style={{ color: stylesTokens.textMain }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
</div>
))}
</div>
{open && (
<div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Neuen User anlegen
</div>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={styles.input}
autoFocus
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Initial Passwort"
type="password"
style={styles.input}
/>
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button
onClick={() => {
resetForm();
setMsg("");
}}
style={styles.secondaryBtn}
>
Leeren
</button>
<button onClick={createUser} style={styles.primaryBtn}>
User erstellen
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Klick auf Item: Grün Rot Grau Leer
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
if (!chipOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}>
{CHIP_LIST.map((c) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{c}
</button>
))}
</div>
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div>
</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,71 @@
import React from "react";
import { styles } from "../styles/styles";
export default function LoginPage({
loginEmail,
setLoginEmail,
loginPassword,
setLoginPassword,
showPw,
setShowPw,
doLogin,
}) {
return (
<div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true">
<div style={styles.bgMap} />
</div>
<div style={styles.candleGlowLayer} aria-hidden="true" />
<div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<div style={styles.inputRow}>
<input
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort"
type={showPw ? "text" : "password"}
style={styles.inputInRow}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
title={showPw ? "Verstecken" : "Anzeigen"}
>
{showPw ? "🙈" : "👁"}
</button>
</div>
</div>
<button onClick={doLogin} style={styles.loginBtn}>
Anmelden
</button>
</div>
<div style={styles.loginHint}>
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function PasswordModal({
pwOpen,
closePwModal,
pw1,
setPw1,
pw2,
setPw2,
pwMsg,
pwSaving,
savePassword,
}) {
if (!pwOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort"
type="password"
style={styles.input}
autoFocus
/>
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen"
type="password"
style={styles.input}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
}}
/>
{pwMsg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{pwMsg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Mindestens 8 Zeichen empfohlen.
</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>
);
}

View File

@@ -0,0 +1,98 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function TopBar({
me,
userMenuOpen,
setUserMenuOpen,
openPwModal,
doLogout,
newGame,
}) {
return (
<div style={styles.topBar}>
{/* LINKS: nur Rolle */}
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
Zauber-Notizbogen
</div>
<div
style={{
fontSize: 12,
opacity: 0.8,
color: stylesTokens.textDim,
}}
>
Eingeloggt als {me.role}
</div>
</div>
{/* RECHTS: Account + Neues Spiel */}
<div
style={{
display: "flex",
gap: 8,
alignItems: "center",
flexWrap: "nowrap",
}}
data-user-menu
>
{/* Account Dropdown */}
<div style={{ position: "relative" }}>
<button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16 }}>👤</span>
<span>Account</span>
<span style={{ opacity: 0.7 }}></span>
</button>
{userMenuOpen && (
<div style={styles.userDropdown}>
{/* Email Info */}
<div
style={{
padding: "10px 12px",
fontSize: 13,
opacity: 0.85,
color: stylesTokens.textDim,
borderBottom: "1px solid rgba(233,216,166,0.12)",
}}
>
{me.email}
</div>
{/* Actions */}
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
</button>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
setUserMenuOpen(false);
doLogout();
}}
style={{
...styles.userDropdownItem,
color: "#ffb3b3",
}}
>
Logout
</button>
</div>
)}
</div>
{/* Neues Spiel Button */}
<button onClick={newGame} style={styles.primaryBtn}>
Neues Spiel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];

View File

@@ -1,5 +1,7 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { registerSW } from "virtual:pwa-register";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);
registerSW({ immediate: true });

View File

@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { stylesTokens } from "../theme";
export function useHpGlobalStyles() {
// Google Fonts
useEffect(() => {
if (document.getElementById("hp-fonts")) return;
const pre1 = document.createElement("link");
pre1.id = "hp-fonts-pre1";
pre1.rel = "preconnect";
pre1.href = "https://fonts.googleapis.com";
const pre2 = document.createElement("link");
pre2.id = "hp-fonts-pre2";
pre2.rel = "preconnect";
pre2.href = "https://fonts.gstatic.com";
pre2.crossOrigin = "anonymous";
const link = document.createElement("link");
link.id = "hp-fonts";
link.rel = "stylesheet";
link.href =
"https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&family=IM+Fell+English:ital,wght@0,400;0,700;1,400&display=swap";
document.head.appendChild(pre1);
document.head.appendChild(pre2);
document.head.appendChild(link);
}, []);
// Keyframes
useEffect(() => {
if (document.getElementById("hp-anim-style")) return;
const style = document.createElement("style");
style.id = "hp-anim-style";
style.innerHTML = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn { from { opacity: 0; transform: translateY(8px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes rowPulse { 0%{ transform: scale(1); } 50%{ transform: scale(1.01); } 100%{ transform: scale(1); } }
@keyframes candleGlow {
0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
70% { opacity: .62; transform: translateY(1px) scale(1.01); filter: blur(17px); }
100% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
}
`;
document.head.appendChild(style);
}, []);
// html/body reset
useEffect(() => {
document.documentElement.style.height = "100%";
document.body.style.height = "100%";
document.documentElement.style.margin = "0";
document.body.style.margin = "0";
document.documentElement.style.padding = "0";
document.body.style.padding = "0";
}, []);
// Global CSS
useEffect(() => {
if (document.getElementById("hp-global-style")) return;
const style = document.createElement("style");
style.id = "hp-global-style";
style.innerHTML = `
html, body {
overscroll-behavior-y: none;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: ${stylesTokens.pageBg};
color: ${stylesTokens.textMain};
}
body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
#root { background: transparent; }
* { -webkit-tap-highlight-color: transparent; }
`;
document.head.appendChild(style);
}, []);
}

View File

@@ -0,0 +1,490 @@
import { stylesTokens } from "./theme";
export const styles = {
page: {
minHeight: "100dvh",
background: "transparent",
position: "relative",
zIndex: 1,
},
shell: {
fontFamily: '"IM Fell English", system-ui',
padding: 16,
maxWidth: 680,
margin: "0 auto",
},
topBar: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
padding: 12,
borderRadius: 16,
background: stylesTokens.panelBg,
border: `1px solid ${stylesTokens.panelBorder}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
card: {
borderRadius: 18,
overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.50)",
boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
},
cardBody: {
padding: 12,
display: "flex",
gap: 10,
alignItems: "center",
},
sectionHeader: {
padding: "11px 14px",
fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.0,
color: stylesTokens.textGold,
background: "linear-gradient(180deg, rgba(32,32,36,0.92), rgba(14,14,16,0.92))",
borderBottom: `1px solid ${stylesTokens.goldLine}`,
textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.6)",
},
row: {
display: "grid",
gridTemplateColumns: "1fr 54px 68px",
gap: 10,
padding: "12px 14px",
alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.08)",
borderLeft: "4px solid rgba(0,0,0,0)",
},
name: {
cursor: "pointer",
userSelect: "none",
fontWeight: 800,
letterSpacing: 0.2,
color: stylesTokens.textMain,
},
statusCell: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
statusBadge: {
width: 34,
height: 34,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
borderRadius: 999,
border: `1px solid rgba(233,216,166,0.18)`,
fontWeight: 1100,
fontSize: 16,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
tagBtn: {
padding: "8px 0",
fontWeight: 1000,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
helpBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
whiteSpace: "nowrap",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
input: {
width: "100%",
padding: 10,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 16,
},
primaryBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
secondaryBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
// Admin
adminWrap: {
marginTop: 14,
padding: 12,
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.14)`,
background: "rgba(18, 18, 20, 0.40)",
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
adminTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
},
adminTitle: {
fontWeight: 1000,
color: stylesTokens.textGold,
},
userRow: {
display: "grid",
gridTemplateColumns: "1fr 80px 90px",
gap: 8,
padding: 10,
borderRadius: 12,
background: "rgba(255,255,255,0.06)",
border: `1px solid rgba(233,216,166,0.10)`,
},
// Modal
modalOverlay: {
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
zIndex: 9999,
animation: "fadeIn 160ms ease-out",
},
modalCard: {
width: "100%",
maxWidth: 560,
borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(180deg, rgba(20,20,24,0.92), rgba(12,12,14,0.86))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 14,
backdropFilter: "blur(6px)",
animation: "popIn 160ms ease-out",
color: stylesTokens.textMain,
},
modalHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
},
modalCloseBtn: {
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
lineHeight: "38px",
textAlign: "center",
},
// Help
helpBody: {
marginTop: 10,
paddingTop: 4,
maxHeight: "70vh",
overflow: "auto",
},
helpSectionTitle: {
fontWeight: 1000,
color: stylesTokens.textGold,
marginTop: 10,
marginBottom: 6,
},
helpText: {
color: stylesTokens.textMain,
opacity: 0.92,
lineHeight: 1.35,
},
helpList: {
marginTop: 10,
display: "grid",
gap: 8,
},
helpListRow: {
display: "grid",
gridTemplateColumns: "42px 1fr",
gap: 10,
alignItems: "center",
color: stylesTokens.textMain,
},
helpBadge: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
fontWeight: 1100,
fontSize: 18,
},
helpMiniTag: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1100,
},
helpDivider: {
margin: "14px 0",
height: 1,
background: "rgba(233,216,166,0.12)",
},
// Login
loginPage: {
minHeight: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
padding: 20,
background: "transparent",
zIndex: 1,
},
loginCard: {
width: "100%",
maxWidth: 420,
padding: 26,
borderRadius: 22,
position: "relative",
zIndex: 2,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(18, 18, 20, 0.55)",
boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
backdropFilter: "blur(8px)",
animation: "popIn 240ms ease-out",
color: stylesTokens.textMain,
},
loginTitle: {
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
fontWeight: 1000,
fontSize: 26,
color: stylesTokens.textGold,
textAlign: "center",
letterSpacing: 0.6,
},
loginSubtitle: {
marginTop: 6,
textAlign: "center",
color: stylesTokens.textMain,
opacity: 0.9,
fontSize: 15,
lineHeight: 1.4,
},
loginFieldWrap: {
width: "100%",
display: "flex",
justifyContent: "center",
},
loginInput: {
width: "100%",
padding: 10,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 16,
},
loginBtn: {
padding: "12px 14px",
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
fontSize: 16,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
loginHint: {
marginTop: 18,
fontSize: 13,
opacity: 0.78,
textAlign: "center",
color: stylesTokens.textDim,
lineHeight: 1.35,
},
candleGlowLayer: {
position: "absolute",
inset: 0,
pointerEvents: "none",
background: `
radial-gradient(circle at 20% 25%, rgba(255, 200, 120, 0.16), rgba(0,0,0,0) 40%),
radial-gradient(circle at 80% 30%, rgba(255, 210, 140, 0.12), rgba(0,0,0,0) 42%),
radial-gradient(circle at 55% 75%, rgba(255, 180, 100, 0.08), rgba(0,0,0,0) 45%)
`,
animation: "candleGlow 3.8s ease-in-out infinite",
mixBlendMode: "multiply",
},
inputRow: {
display: "flex",
alignItems: "stretch",
width: "100%",
},
inputInRow: {
flex: 1,
padding: 10,
borderRadius: "12px 0 0 12px",
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain,
outline: "none",
minWidth: 0,
fontSize: 16,
},
pwToggleBtn: {
width: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0 12px 12px 0",
border: `1px solid rgba(233,216,166,0.18)`,
borderLeft: "none",
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
cursor: "pointer",
fontWeight: 900,
padding: 0,
},
// Background
bgFixed: {
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100dvh",
zIndex: -1,
pointerEvents: "none",
transform: "translateZ(0)",
backfaceVisibility: "hidden",
willChange: "transform",
},
bgMap: {
position: "absolute",
inset: 0,
backgroundImage: 'url("/bg/marauders-map-blur.jpg")',
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
},
chipGrid: {
marginTop: 12,
display: "grid",
gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
gap: 8,
},
chipBtn: {
padding: "10px 14px",
borderRadius: 12,
border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
minWidth: 64,
},
userBtn: {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
maxWidth: 180,
},
userDropdown: {
position: "absolute",
right: 0,
top: "calc(100% + 8px)",
minWidth: 220,
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden",
zIndex: 10000,
backdropFilter: "blur(8px)",
},
userDropdownItem: {
width: "100%",
textAlign: "left",
padding: "10px 12px",
border: "none",
background: "transparent",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
},
userDropdownDivider: {
height: 1,
background: "rgba(233,216,166,0.12)",
},
};

View File

@@ -0,0 +1,11 @@
export const stylesTokens = {
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)",
};

View File

@@ -0,0 +1,23 @@
function chipStorageKey(gameId, entryId) {
return `chip:${gameId}:${entryId}`;
}
export function getChipLS(gameId, entryId) {
try {
return localStorage.getItem(chipStorageKey(gameId, entryId));
} catch {
return null;
}
}
export function setChipLS(gameId, entryId, chip) {
try {
localStorage.setItem(chipStorageKey(gameId, entryId), chip);
} catch {}
}
export function clearChipLS(gameId, entryId) {
try {
localStorage.removeItem(chipStorageKey(gameId, entryId));
} catch {}
}

View File

@@ -0,0 +1,11 @@
/**
* Backend erlaubt: null | "i" | "m" | "s"
* Rotation:
* null -> i -> m -> s (Popup) -> null
*/
export function cycleTag(tag) {
if (!tag) return "i";
if (tag === "i") return "m";
if (tag === "m") return "s";
return null;
}

View File

@@ -1,6 +1,34 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: [
"favicon.ico",
"icons/icon-512.png",
"marauders-map.jpg" // oder dein Hintergrund
],
manifest: {
name: "Zauber-Detektiv Notizbogen",
short_name: "Notizbogen",
description: "Cluedo-Magie Sheet",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#1c140d",
theme_color: "#caa45a",
icons: [
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }
]
},
workbox: {
// Caching-Default: die App-Shell wird offline verfügbar
globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"],
}
})
]
}); });