Compare commits
12 Commits
6a5ff44135
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 62eb7e6e58 | |||
| 7036f29481 | |||
| bf37850e79 | |||
| a9dbbd65a4 | |||
| ca15266fb9 | |||
| e2b97b69e9 | |||
| 59f477c343 | |||
| 7be21969e7 | |||
| db35a7b0c9 | |||
| d2e2286627 | |||
| b8fc47e881 | |||
| 2ec7c63119 |
@@ -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}
|
||||||
@@ -13,25 +13,44 @@ async function api(path, opts = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTag(tag) {
|
/**
|
||||||
// erlaubt: null | "i" | "m" | "s" | "s.AL"
|
* Backend erlaubt: null | "i" | "m" | "s"
|
||||||
if (!tag) return { base: null, chip: null };
|
* Rotation:
|
||||||
if (tag === "i") return { base: "i", chip: null };
|
* null -> i -> m -> s (Popup) -> null
|
||||||
if (tag === "m") return { base: "m", chip: null };
|
*/
|
||||||
if (tag === "s") return { base: "s", chip: null };
|
function cycleTag(tag) {
|
||||||
if (tag.startsWith("s.")) return { base: "s", chip: tag.slice(2) || null };
|
if (!tag) return "i";
|
||||||
return { base: tag, chip: null };
|
if (tag === "i") return "m";
|
||||||
|
if (tag === "m") return "s";
|
||||||
|
return null; // "s" -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextBaseTag(tag) {
|
/* ========= Chip localStorage (Frontend-only) ========= */
|
||||||
const { base } = parseTag(tag);
|
function chipStorageKey(gameId, entryId) {
|
||||||
if (!base) return "i";
|
return `chip:${gameId}:${entryId}`;
|
||||||
if (base === "i") return "m";
|
|
||||||
if (base === "m") return "s"; // hier öffnen wir dann das Popup
|
|
||||||
// wenn s (egal ob s oder s.XY), dann zurück auf leer
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChipLS(gameId, entryId) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(chipStorageKey(gameId, entryId));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChipLS(gameId, entryId, chip) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(chipStorageKey(gameId, entryId), chip);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChipLS(gameId, entryId) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(chipStorageKey(gameId, entryId));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= Admin Panel ========= */
|
||||||
function AdminPanel() {
|
function AdminPanel() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
@@ -164,6 +183,7 @@ function AdminPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========= App ========= */
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [me, setMe] = useState(null);
|
const [me, setMe] = useState(null);
|
||||||
const [loginEmail, setLoginEmail] = useState("");
|
const [loginEmail, setLoginEmail] = useState("");
|
||||||
@@ -175,11 +195,21 @@ export default function App() {
|
|||||||
const [sheet, setSheet] = useState(null);
|
const [sheet, setSheet] = useState(null);
|
||||||
const [pulseId, setPulseId] = useState(null);
|
const [pulseId, setPulseId] = useState(null);
|
||||||
|
|
||||||
const [chipPickOpen, setChipPickOpen] = useState(false);
|
// Chip popup
|
||||||
const [chipPickEntry, setChipPickEntry] = useState(null);
|
const [chipOpen, setChipOpen] = useState(false);
|
||||||
|
const [chipEntry, setChipEntry] = useState(null);
|
||||||
|
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
|
// User dropdown + Passwort Modal
|
||||||
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
|
const [pw1, setPw1] = useState("");
|
||||||
|
const [pw2, setPw2] = useState("");
|
||||||
|
const [pwMsg, setPwMsg] = useState("");
|
||||||
|
const [pwSaving, setPwSaving] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const m = await api("/auth/me");
|
const m = await api("/auth/me");
|
||||||
setMe(m);
|
setMe(m);
|
||||||
@@ -190,6 +220,18 @@ export default function App() {
|
|||||||
if (gs[0] && !gameId) setGameId(gs[0].id);
|
if (gs[0] && !gameId) setGameId(gs[0].id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Usermanager
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e) => {
|
||||||
|
// wenn Dropdown offen & Klick ist NICHT in einem Element mit data-user-menu
|
||||||
|
const root = e.target?.closest?.("[data-user-menu]");
|
||||||
|
if (!root) setUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userMenuOpen) document.addEventListener("mousedown", onDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
// Google Fonts
|
// Google Fonts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.getElementById("hp-fonts")) return;
|
if (document.getElementById("hp-fonts")) return;
|
||||||
@@ -225,7 +267,6 @@ export default function App() {
|
|||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
@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 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 rowPulse { 0%{ transform: scale(1); } 50%{ transform: scale(1.01); } 100%{ transform: scale(1); } }
|
||||||
|
|
||||||
@keyframes candleGlow {
|
@keyframes candleGlow {
|
||||||
0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
|
0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
|
||||||
35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
|
35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
|
||||||
@@ -246,7 +287,7 @@ export default function App() {
|
|||||||
document.body.style.padding = "0";
|
document.body.style.padding = "0";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global CSS: Dark + Gold base
|
// Global CSS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.getElementById("hp-global-style")) return;
|
if (document.getElementById("hp-global-style")) return;
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
@@ -261,15 +302,11 @@ export default function App() {
|
|||||||
background: ${stylesTokens.pageBg};
|
background: ${stylesTokens.pageBg};
|
||||||
color: ${stylesTokens.textMain};
|
color: ${stylesTokens.textMain};
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root { background: transparent; }
|
#root { background: transparent; }
|
||||||
|
|
||||||
/* Safari/Chrome tap highlight reduzieren (kein hover/flash) */
|
|
||||||
* { -webkit-tap-highlight-color: transparent; }
|
* { -webkit-tap-highlight-color: transparent; }
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@@ -314,6 +351,52 @@ export default function App() {
|
|||||||
setSheet(null);
|
setSheet(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Password change =====
|
||||||
|
const openPwModal = () => {
|
||||||
|
setPwMsg("");
|
||||||
|
setPw1("");
|
||||||
|
setPw2("");
|
||||||
|
setPwOpen(true);
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePwModal = () => {
|
||||||
|
setPwOpen(false);
|
||||||
|
setPwMsg("");
|
||||||
|
setPw1("");
|
||||||
|
setPw2("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
setPwMsg("");
|
||||||
|
|
||||||
|
if (!pw1 || pw1.length < 8) {
|
||||||
|
setPwMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pw1 !== pw2) {
|
||||||
|
setPwMsg("❌ Passwörter stimmen nicht überein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPwSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/auth/password", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ password: pw1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setPwMsg("✅ Passwort gespeichert.");
|
||||||
|
// Optional: nach kurzer Zeit automatisch schließen
|
||||||
|
setTimeout(() => closePwModal(), 650);
|
||||||
|
} catch (e) {
|
||||||
|
// dein api() wirft res.text() -> oft JSON Detail. Wir zeigen es einfach roh.
|
||||||
|
setPwMsg("❌ Fehler: " + (e?.message || "unknown"));
|
||||||
|
} finally {
|
||||||
|
setPwSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const newGame = async () => {
|
const newGame = async () => {
|
||||||
const g = await api("/games", {
|
const g = await api("/games", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -347,40 +430,93 @@ export default function App() {
|
|||||||
setTimeout(() => setPulseId(null), 220);
|
setTimeout(() => setPulseId(null), 220);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Notiz-Button: i -> m -> (Popup) s -> null
|
||||||
const toggleTag = async (entry) => {
|
const toggleTag = async (entry) => {
|
||||||
const next = nextBaseTag(entry.note_tag);
|
const next = cycleTag(entry.note_tag);
|
||||||
|
|
||||||
// Wenn wir bei "s" angekommen sind -> Popup öffnen statt sofort setzen
|
// Wenn wir zu "s" gehen würden -> Chip Popup öffnen, aber NICHT ins Backend schreiben
|
||||||
if (next === "s") {
|
if (next === "s") {
|
||||||
setChipPickEntry(entry);
|
setChipEntry(entry);
|
||||||
setChipPickOpen(true);
|
setChipOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// normal setzen (— / i / m)
|
// Wenn wir auf null gehen, Chip lokal löschen (weil s -> —)
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeChipPick = () => {
|
// Chip wählen:
|
||||||
setChipPickOpen(false);
|
// Backend: note_tag = "s"
|
||||||
setChipPickEntry(null);
|
// Frontend: Chip in localStorage
|
||||||
};
|
|
||||||
|
|
||||||
const chooseChip = async (chip) => {
|
const chooseChip = async (chip) => {
|
||||||
if (!chipPickEntry) return;
|
if (!chipEntry) return;
|
||||||
|
|
||||||
await api(`/games/${gameId}/sheet/${chipPickEntry.entry_id}`, {
|
// UI sofort schließen -> fühlt sich besser an
|
||||||
method: "PATCH",
|
const entry = chipEntry;
|
||||||
body: JSON.stringify({ note_tag: `s.${chip}` }),
|
setChipOpen(false);
|
||||||
});
|
setChipEntry(null);
|
||||||
|
|
||||||
closeChipPick();
|
// local speichern
|
||||||
await reloadSheet();
|
setChipLS(gameId, entry.entry_id, chip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Backend bekommt nur "s"
|
||||||
|
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ note_tag: "s" }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await reloadSheet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// X im Modal:
|
||||||
|
// Backend zurück auf null und lokalen Chip löschen
|
||||||
|
const closeChipModalToDash = async () => {
|
||||||
|
if (!chipEntry) {
|
||||||
|
setChipOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI sofort schließen
|
||||||
|
const entry = chipEntry;
|
||||||
|
setChipOpen(false);
|
||||||
|
setChipEntry(null);
|
||||||
|
|
||||||
|
// Frontend-only Chip entfernen
|
||||||
|
clearChipLS(gameId, entry.entry_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Backend zurück auf —
|
||||||
|
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ note_tag: null }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await reloadSheet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anzeige im Tag-Button:
|
||||||
|
// - "s" wird zu "s.AL" (aus localStorage), sonst "s"
|
||||||
|
const displayTag = (entry) => {
|
||||||
|
const t = entry.note_tag;
|
||||||
|
if (!t) return "—";
|
||||||
|
|
||||||
|
if (t === "s") {
|
||||||
|
const chip = getChipLS(gameId, entry.entry_id);
|
||||||
|
return chip ? `s.${chip}` : "s"; // <-- genau wie gewünscht
|
||||||
|
}
|
||||||
|
|
||||||
|
return t; // i oder m
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -408,7 +544,8 @@ export default function App() {
|
|||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
if (status === 2) return { color: "#baf3c9", background: "rgba(0,190,80,0.18)" };
|
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 === 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)" };
|
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 { color: "rgba(233,216,166,0.75)", background: "rgba(255,255,255,0.08)" };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,9 +564,7 @@ export default function App() {
|
|||||||
<div style={styles.loginCard}>
|
<div style={styles.loginCard}>
|
||||||
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
|
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
|
||||||
|
|
||||||
<div style={styles.loginSubtitle}>
|
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
|
||||||
Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
|
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
|
||||||
<div style={styles.loginFieldWrap}>
|
<div style={styles.loginFieldWrap}>
|
||||||
@@ -497,15 +632,42 @@ export default function App() {
|
|||||||
<div style={styles.topBar}>
|
<div style={styles.topBar}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>{me.email}</div>
|
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>{me.email}</div>
|
||||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
|
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.role}</div>
|
||||||
{me.role}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }} data-user-menu>
|
||||||
<button onClick={doLogout} style={styles.secondaryBtn}>
|
<div style={{ position: "relative" }}>
|
||||||
Logout
|
<button
|
||||||
</button>
|
onClick={() => setUserMenuOpen((v) => !v)}
|
||||||
|
style={styles.userBtn}
|
||||||
|
title="User Menü"
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1 }}>👤</span>
|
||||||
|
<span>Account</span>
|
||||||
|
<span style={{ opacity: 0.8 }}>▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div style={styles.userDropdown}>
|
||||||
|
<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>
|
||||||
|
|
||||||
<button onClick={newGame} style={styles.primaryBtn}>
|
<button onClick={newGame} style={styles.primaryBtn}>
|
||||||
+ Neues Spiel
|
+ Neues Spiel
|
||||||
</button>
|
</button>
|
||||||
@@ -558,25 +720,45 @@ export default function App() {
|
|||||||
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}>
|
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}>
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
<div><b>Grün</b> = bestätigt / fix richtig</div>
|
<div>
|
||||||
|
<b>Grün</b> = bestätigt / fix richtig
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}>
|
<span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}>
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
<div><b>Rot</b> = ausgeschlossen / fix falsch</div>
|
<div>
|
||||||
|
<b>Rot</b> = ausgeschlossen / fix falsch
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={{ ...styles.helpBadge, background: "rgba(140,140,140,0.14)", color: "rgba(233,216,166,0.85)" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.helpBadge,
|
||||||
|
background: "rgba(140,140,140,0.14)",
|
||||||
|
color: "rgba(233,216,166,0.85)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
?
|
?
|
||||||
</span>
|
</span>
|
||||||
<div><b>Grau</b> = unsicher / „vielleicht“</div>
|
<div>
|
||||||
|
<b>Grau</b> = unsicher / „vielleicht“
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}>
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.helpBadge,
|
||||||
|
background: "rgba(255,255,255,0.08)",
|
||||||
|
color: "rgba(233,216,166,0.75)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
–
|
–
|
||||||
</span>
|
</span>
|
||||||
<div><b>Leer</b> = unknown / noch nicht bewertet</div>
|
<div>
|
||||||
|
<b>Leer</b> = unknown / noch nicht bewertet
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,73 +772,115 @@ export default function App() {
|
|||||||
<div style={styles.helpList}>
|
<div style={styles.helpList}>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={styles.helpMiniTag}>i</span>
|
<span style={styles.helpMiniTag}>i</span>
|
||||||
<div><b>i</b> = „Ich habe diese Geheimkarte“</div>
|
<div>
|
||||||
|
<b>i</b> = „Ich habe diese Geheimkarte“
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={styles.helpMiniTag}>m</span>
|
<span style={styles.helpMiniTag}>m</span>
|
||||||
<div><b>m</b> = „Geheimkarte aus dem mittleren Deck“</div>
|
<div>
|
||||||
|
<b>m</b> = „Geheimkarte aus dem mittleren Deck“
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={styles.helpMiniTag}>s</span>
|
<span style={styles.helpMiniTag}>s</span>
|
||||||
<div><b>s</b> = „Ein anderer Spieler hat diese Karte“</div>
|
<div>
|
||||||
|
<b>s</b> = „Ein anderer Spieler hat diese Karte“ (Chip Auswahl)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.helpListRow}>
|
<div style={styles.helpListRow}>
|
||||||
<span style={styles.helpMiniTag}>—</span>
|
<span style={styles.helpMiniTag}>—</span>
|
||||||
<div><b>—</b> = keine Notiz</div>
|
<div>
|
||||||
|
<b>—</b> = keine Notiz
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.helpDivider} />
|
<div style={styles.helpDivider} />
|
||||||
|
|
||||||
<div style={styles.helpText}>
|
<div style={styles.helpText}>
|
||||||
Tipp: Jeder Spieler sieht nur seine eigenen Notizen – andere Spieler können nicht in deinen Zettel schauen.
|
Tipp: Jeder Spieler sieht nur seine eigenen Notizen – andere Spieler können nicht in deinen
|
||||||
|
Zettel schauen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chipPickOpen && (
|
{pwOpen && (
|
||||||
<div style={styles.modalOverlay} onMouseDown={closeChipPick}>
|
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
|
||||||
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
<div style={styles.modalHeader}>
|
<div style={styles.modalHeader}>
|
||||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
|
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
|
||||||
Wer hat die Karte?
|
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={closeChipPick}
|
|
||||||
style={styles.modalCloseBtn}
|
|
||||||
aria-label="Schließen"
|
|
||||||
>
|
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.9 }}>
|
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||||
Chip auswählen:
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chip Popup */}
|
||||||
|
{chipOpen && (
|
||||||
|
<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}>
|
<div style={styles.chipGrid}>
|
||||||
{CHIP_LIST.map((c) => (
|
{CHIP_LIST.map((c) => (
|
||||||
<button
|
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
|
||||||
key={c}
|
|
||||||
onClick={() => chooseChip(c)}
|
|
||||||
style={styles.chipBtn}
|
|
||||||
title={`Setze s.${c}`}
|
|
||||||
>
|
|
||||||
{c}
|
{c}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 10, fontSize: 12, color: stylesTokens.textDim }}>
|
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
|
||||||
Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von <b>s.XX</b> zurück auf <b>—</b>.
|
Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von <b>s.XX</b> zurück auf —.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
|
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
|
||||||
{sections.map((sec) => (
|
{sections.map((sec) => (
|
||||||
<div key={sec.key} style={styles.card}>
|
<div key={sec.key} style={styles.card}>
|
||||||
@@ -664,29 +888,37 @@ export default function App() {
|
|||||||
|
|
||||||
<div style={{ display: "grid" }}>
|
<div style={{ display: "grid" }}>
|
||||||
{sec.entries.map((e) => {
|
{sec.entries.map((e) => {
|
||||||
const badge = getStatusBadge(e.status);
|
// UI "rot" wenn note_tag i oder 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={e.entry_id}
|
key={e.entry_id}
|
||||||
className="hp-row"
|
className="hp-row"
|
||||||
style={{
|
style={{
|
||||||
...styles.row,
|
...styles.row,
|
||||||
background: getRowBg(e.status),
|
background: getRowBg(effectiveStatus),
|
||||||
animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none",
|
animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none",
|
||||||
borderLeft:
|
borderLeft:
|
||||||
e.status === 2 ? "4px solid rgba(0,190,80,0.55)" :
|
effectiveStatus === 2
|
||||||
e.status === 1 ? "4px solid rgba(255,35,35,0.55)" :
|
? "4px solid rgba(0,190,80,0.55)"
|
||||||
e.status === 3 ? "4px solid rgba(233,216,166,0.22)" :
|
: effectiveStatus === 1
|
||||||
"4px solid rgba(0,0,0,0)",
|
? "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
|
<div
|
||||||
onClick={() => cycleStatus(e)}
|
onClick={() => cycleStatus(e)}
|
||||||
style={{
|
style={{
|
||||||
...styles.name,
|
...styles.name,
|
||||||
textDecoration: e.status === 1 ? "line-through" : "none",
|
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
|
||||||
color: getNameColor(e.status),
|
color: getNameColor(effectiveStatus),
|
||||||
opacity: e.status === 1 ? 0.8 : 1,
|
opacity: effectiveStatus === 1 ? 0.8 : 1,
|
||||||
}}
|
}}
|
||||||
title="Klick: Grün → Rot → Grau → Leer"
|
title="Klick: Grün → Rot → Grau → Leer"
|
||||||
>
|
>
|
||||||
@@ -695,12 +927,12 @@ export default function App() {
|
|||||||
|
|
||||||
<div style={styles.statusCell}>
|
<div style={styles.statusCell}>
|
||||||
<span style={{ ...styles.statusBadge, color: badge.color, background: badge.background }}>
|
<span style={{ ...styles.statusBadge, color: badge.color, background: badge.background }}>
|
||||||
{getStatusSymbol(e.status)}
|
{getStatusSymbol(effectiveStatus)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={() => toggleTag(e)} style={styles.tagBtn} title="i → m → s → leer">
|
<button onClick={() => toggleTag(e)} style={styles.tagBtn} title="— → i → m → s.(Chip) → —">
|
||||||
{e.note_tag || "—"}
|
{displayTag(e)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1152,7 +1384,6 @@ const styles = {
|
|||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
backgroundPosition: "center",
|
backgroundPosition: "center",
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
/* kleines Dark-Wash, damit Schwarz/Gold lesbar ist */
|
|
||||||
filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
|
filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1164,14 +1395,59 @@ const styles = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
chipBtn: {
|
chipBtn: {
|
||||||
padding: "10px 0",
|
padding: "10px 14px",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: `1px solid rgba(233,216,166,0.22)`,
|
border: "1px solid rgba(233,216,166,0.18)",
|
||||||
background: "rgba(255,255,255,0.06)",
|
background: "rgba(255,255,255,0.06)",
|
||||||
color: stylesTokens.textGold,
|
color: stylesTokens.textGold,
|
||||||
fontWeight: 1100,
|
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",
|
cursor: "pointer",
|
||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
|
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)",
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user