Compare commits
10
Commits
33a972e502
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
209f9c10dd | ||
|
|
d672648a05 | ||
|
|
ea08016ea5 | ||
|
|
c9eae136fb | ||
|
|
ee8ae8829e | ||
|
|
b20056913b | ||
|
|
565d538c35 | ||
|
|
45fc8e4c9e | ||
|
|
e7d288ff12 | ||
|
|
33caa8f792 |
+10
-6
@@ -23,9 +23,13 @@ def send_html_email(settings: AppSettings, recipient: str, subject: str, body_ht
|
||||
message.add_alternative(body_html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server:
|
||||
security = getattr(settings, "smtp_security", "") or ("starttls" if settings.smtp_use_tls else "none")
|
||||
port = settings.smtp_port or (465 if security == "ssl" else 587)
|
||||
smtp_client = smtplib.SMTP_SSL if security == "ssl" else smtplib.SMTP
|
||||
connection = smtp_client(settings.smtp_host, port, timeout=20, context=context) if security == "ssl" else smtp_client(settings.smtp_host, port, timeout=20)
|
||||
with connection as server:
|
||||
server.ehlo()
|
||||
if settings.smtp_use_tls:
|
||||
if security == "starttls":
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
if settings.smtp_username:
|
||||
@@ -46,10 +50,10 @@ def send_user_invite(settings: AppSettings, user: User, invite_url: str):
|
||||
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
|
||||
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
|
||||
</div>
|
||||
<div style="padding:30px;line-height:1.55;">
|
||||
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div>
|
||||
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
|
||||
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p>
|
||||
<div style="padding:30px;line-height:1.55;color:#f5efdc !important;background:#17161b;">
|
||||
<div style="font-size:20px;color:#e9d8a6 !important;">Hallo {name},</div>
|
||||
<p style="color:#f5efdc !important;margin:16px 0;">du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
|
||||
<p style="color:#f5efdc !important;margin:16px 0;">Richte über den folgenden Button dein persönliches Passwort ein:</p>
|
||||
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
|
||||
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
|
||||
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
|
||||
|
||||
@@ -88,6 +88,14 @@ Very small, pragmatic auto-migration (no alembic).
|
||||
- supports old schema (join_code/chip_code) and new schema (code/chip)
|
||||
"""
|
||||
|
||||
# --- app settings: explicit SMTP security mode (none/starttls/ssl) ---
|
||||
if not _has_column(db, "app_settings", "smtp_security"):
|
||||
try:
|
||||
db.execute(text("ALTER TABLE app_settings ADD COLUMN smtp_security VARCHAR DEFAULT 'starttls'"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# --- users.display_name ---
|
||||
if not _has_column(db, "users", "display_name"):
|
||||
try:
|
||||
|
||||
@@ -52,6 +52,7 @@ class AppSettings(Base):
|
||||
smtp_from_email: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP")
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
smtp_security: Mapped[str] = mapped_column(String, default="starttls")
|
||||
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from ..db import get_db
|
||||
from ..mailer import send_html_email, send_user_invite
|
||||
from ..models import AppSettings, InviteToken, User, Role
|
||||
from ..models import AppSettings, Game, GameChip, GameMember, InviteToken, SheetState, User, Role
|
||||
from ..security import hash_password, get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
@@ -84,6 +84,28 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
|
||||
|
||||
@router.delete("/users/{user_id}/permanent")
|
||||
def permanently_delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
|
||||
admin = require_admin(req, db)
|
||||
if admin.id == user_id:
|
||||
raise HTTPException(400, "cannot delete yourself")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(404, "not found")
|
||||
if db.query(Game).filter(Game.host_user_id == user_id).first():
|
||||
raise HTTPException(409, "cannot delete a user who owns games")
|
||||
|
||||
db.query(Game).filter(Game.winner_user_id == user_id).update({Game.winner_user_id: None}, synchronize_session=False)
|
||||
db.query(GameMember).filter(GameMember.user_id == user_id).delete(synchronize_session=False)
|
||||
db.query(GameChip).filter(GameChip.user_id == user_id).delete(synchronize_session=False)
|
||||
db.query(SheetState).filter(SheetState.owner_user_id == user_id).delete(synchronize_session=False)
|
||||
db.query(InviteToken).filter(InviteToken.user_id == user_id).delete(synchronize_session=False)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": True}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
|
||||
admin = require_admin(req, db)
|
||||
@@ -163,6 +185,7 @@ def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
|
||||
"smtp_password_configured": bool(settings.smtp_password),
|
||||
"smtp_from_email": settings.smtp_from_email,
|
||||
"smtp_from_name": settings.smtp_from_name,
|
||||
"smtp_security": getattr(settings, "smtp_security", "starttls") or ("starttls" if settings.smtp_use_tls else "none"),
|
||||
"smtp_use_tls": settings.smtp_use_tls,
|
||||
"app_base_url": settings.app_base_url,
|
||||
}
|
||||
@@ -181,7 +204,11 @@ def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)
|
||||
settings.smtp_username = (data.get("smtp_username") or "").strip()
|
||||
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
|
||||
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
|
||||
settings.smtp_use_tls = bool(data.get("smtp_use_tls", True))
|
||||
security = data.get("smtp_security") or ("starttls" if data.get("smtp_use_tls", True) else "none")
|
||||
if security not in ("none", "starttls", "ssl"):
|
||||
raise HTTPException(400, "invalid SMTP security mode")
|
||||
settings.smtp_security = security
|
||||
settings.smtp_use_tls = security == "starttls"
|
||||
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
|
||||
if "smtp_password" in data and data.get("smtp_password"):
|
||||
settings.smtp_password = data["smtp_password"]
|
||||
|
||||
@@ -205,6 +205,22 @@ def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
return {"ok": True, "started": True}
|
||||
|
||||
|
||||
@router.delete("/{game_id}")
|
||||
def cancel_game(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = require_game_member(db, game_id, uid)
|
||||
if g.host_user_id != uid:
|
||||
raise HTTPException(403, "only host can cancel the game")
|
||||
|
||||
# Remove dependent game state before deleting the game itself.
|
||||
db.query(SheetState).filter(SheetState.game_id == game_id).delete(synchronize_session=False)
|
||||
db.query(GameChip).filter(GameChip.game_id == game_id).delete(synchronize_session=False)
|
||||
db.query(GameMember).filter(GameMember.game_id == game_id).delete(synchronize_session=False)
|
||||
db.delete(g)
|
||||
db.commit()
|
||||
return {"ok": True, "cancelled": True}
|
||||
|
||||
|
||||
@router.get("/{game_id}/chips")
|
||||
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"canvas-confetti": "^1.9.3"
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
+53
-2
@@ -27,6 +27,7 @@ import NewGameModal from "./components/NewGameModal";
|
||||
import StatsModal from "./components/StatsModal";
|
||||
import AdminSettingsModal from "./components/AdminSettingsModal";
|
||||
import InvitePage from "./components/InvitePage";
|
||||
import HomePage from "./components/HomePage";
|
||||
import { useLanguage } from "./i18n";
|
||||
|
||||
export default function App() {
|
||||
@@ -59,6 +60,7 @@ export default function App() {
|
||||
|
||||
// Winner selection (host only)
|
||||
const [winnerUserId, setWinnerUserId] = useState("");
|
||||
const winnerSelectionDirtyRef = useRef(false);
|
||||
|
||||
// Modals
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
@@ -134,7 +136,7 @@ export default function App() {
|
||||
const gs = await api("/games");
|
||||
setGames(gs);
|
||||
|
||||
if (gs[0] && !gameId) setGameId(gs[0].id);
|
||||
// Always open on the start page; the user explicitly chooses a game.
|
||||
};
|
||||
|
||||
const reloadSheet = async () => {
|
||||
@@ -148,7 +150,9 @@ export default function App() {
|
||||
|
||||
const meta = await api(`/games/${gameId}`);
|
||||
setGameMeta(meta);
|
||||
if (!winnerSelectionDirtyRef.current) {
|
||||
setWinnerUserId(meta?.winner_user_id || "");
|
||||
}
|
||||
|
||||
const chips = meta?.started ? await api(`/games/${gameId}/chips`) : [];
|
||||
setGameChips(chips || []);
|
||||
@@ -225,6 +229,7 @@ export default function App() {
|
||||
lastWinnerIdRef.current = null;
|
||||
gameStartBaselineRef.current = false;
|
||||
lastGameStartedRef.current = false;
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
setCelebrateOpen(false);
|
||||
setCelebrateName("");
|
||||
setStartCelebrateOpen(false);
|
||||
@@ -369,6 +374,7 @@ export default function App() {
|
||||
setMembers([]);
|
||||
setGameChips([]);
|
||||
setWinnerUserId("");
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
|
||||
// reset winner celebration on logout
|
||||
winnerBaselineRef.current = false;
|
||||
@@ -477,6 +483,7 @@ export default function App() {
|
||||
setMembers([]);
|
||||
setGameChips([]);
|
||||
setWinnerUserId("");
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
setPulseId(null);
|
||||
|
||||
// auch Chip-Modal-State resetten
|
||||
@@ -523,6 +530,38 @@ export default function App() {
|
||||
await loadGameMeta();
|
||||
};
|
||||
|
||||
const cancelGame = async () => {
|
||||
if (!gameId || !isHost) return;
|
||||
await api(`/games/${gameId}`, { method: "DELETE" });
|
||||
const nextGames = await api("/games");
|
||||
setGames(nextGames);
|
||||
setGameId(null);
|
||||
setSheet(null);
|
||||
setGameMeta(null);
|
||||
setMembers([]);
|
||||
setGameChips([]);
|
||||
setWinnerUserId("");
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
showSnack(language === "en" ? "Game cancelled." : "Spiel abgebrochen.");
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
setGameId(null);
|
||||
setSheet(null);
|
||||
setGameMeta(null);
|
||||
setMembers([]);
|
||||
setGameChips([]);
|
||||
setWinnerUserId("");
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
setCelebrateOpen(false);
|
||||
setCelebrateName("");
|
||||
setStartCelebrateOpen(false);
|
||||
};
|
||||
|
||||
// ===== Winner =====
|
||||
const saveWinner = async () => {
|
||||
if (!gameId) return;
|
||||
@@ -530,9 +569,15 @@ export default function App() {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
||||
});
|
||||
winnerSelectionDirtyRef.current = false;
|
||||
await loadGameMeta();
|
||||
};
|
||||
|
||||
const selectWinner = (value) => {
|
||||
winnerSelectionDirtyRef.current = true;
|
||||
setWinnerUserId(value);
|
||||
};
|
||||
|
||||
// ===== Sheet actions =====
|
||||
const cycleStatus = async (entry) => {
|
||||
if (gameMeta?.winner_user_id) return;
|
||||
@@ -719,6 +764,9 @@ export default function App() {
|
||||
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
{!gameId ? (
|
||||
<HomePage games={games} onOpenNewGame={() => setNewGameOpen(true)} onOpenGame={setGameId} />
|
||||
) : <>
|
||||
<GamePickerCard
|
||||
games={games}
|
||||
gameId={gameId}
|
||||
@@ -733,6 +781,8 @@ export default function App() {
|
||||
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
|
||||
chipCount={gameChips.length}
|
||||
onStartGame={startGame}
|
||||
onCancelGame={cancelGame}
|
||||
onGoHome={goHome}
|
||||
/>
|
||||
|
||||
{gameStarted && (
|
||||
@@ -743,6 +793,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>}
|
||||
|
||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
|
||||
@@ -769,7 +820,7 @@ export default function App() {
|
||||
isHost={isHost}
|
||||
members={members}
|
||||
winnerUserId={winnerUserId}
|
||||
setWinnerUserId={setWinnerUserId}
|
||||
setWinnerUserId={selectWinner}
|
||||
onSave={saveWinner}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -106,6 +106,19 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
}
|
||||
};
|
||||
|
||||
const permanentlyDeleteUser = async (user) => {
|
||||
if (user.id === currentUserId) return;
|
||||
const label = user.display_name || user.email;
|
||||
if (!window.confirm(language === "en" ? `Delete ${label} permanently? This cannot be undone.` : `${label} wirklich dauerhaft löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`)) return;
|
||||
try {
|
||||
await api(`/admin/users/${user.id}/permanent`, { method: "DELETE" });
|
||||
await loadUsers();
|
||||
setNotice(language === "en" ? "✅ User permanently deleted." : "✅ User dauerhaft gelöscht.");
|
||||
} catch (e) {
|
||||
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
if (!dashboardOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -136,6 +149,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
<div className="hp-admin-actions">
|
||||
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}>✎</button>
|
||||
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}>⏻</button>
|
||||
<button className="hp-admin-action hp-admin-icon-action" onClick={() => permanentlyDeleteUser(user)} disabled={user.id === currentUserId} style={{ ...styles.secondaryBtn, color: "#ff8f9b" }} title={language === "en" ? "Delete permanently" : "Dauerhaft löschen"} aria-label={`${user.display_name || user.email} ${language === "en" ? "delete permanently" : "dauerhaft löschen"}`}>🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLanguage } from "../i18n";
|
||||
|
||||
const initial = {
|
||||
smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "",
|
||||
smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_use_tls: true,
|
||||
smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_security: "starttls",
|
||||
app_base_url: window.location.origin,
|
||||
};
|
||||
|
||||
@@ -72,7 +72,11 @@ export default function AdminSettingsModal({ open, onClose }) {
|
||||
<label style={styles.adminFieldLabel}>{t("senderName")}<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label>
|
||||
</div>
|
||||
<label style={styles.adminFieldLabel}>{t("appUrl")}<input value={form.app_base_url} onChange={(e) => setField("app_base_url", e.target.value)} placeholder="https://notizbogen.example.com" style={styles.input} /></label>
|
||||
<label className="hp-admin-check"><input type="checkbox" checked={!!form.smtp_use_tls} onChange={(e) => setField("smtp_use_tls", e.target.checked)} /> {t("useTls")}</label>
|
||||
<label style={styles.adminFieldLabel}>{language === "en" ? "SMTP encryption" : "SMTP-Verschlüsselung"}<select value={form.smtp_security || "starttls"} onChange={(e) => { const security = e.target.value; setField("smtp_security", security); if (security === "ssl" && String(form.smtp_port) === "587") setField("smtp_port", 465); if (security === "starttls" && String(form.smtp_port) === "465") setField("smtp_port", 587); }} style={styles.input}>
|
||||
<option value="none">{language === "en" ? "None" : "Keine Verschlüsselung"}</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="ssl">{language === "en" ? "TLS/SSL (direct, port 465)" : "TLS/SSL (direkt, Port 465)"}</option>
|
||||
</select></label>
|
||||
<div className="hp-settings-test">
|
||||
<label style={{ ...styles.adminFieldLabel, flex: 1 }}>{t("testEmail")}<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="you@example.com" style={styles.input} /></label>
|
||||
<button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? (language === "en" ? "Sending …" : "Sende …") : t("sendTest")}</button>
|
||||
|
||||
@@ -17,11 +17,14 @@ export default function GamePickerCard({
|
||||
winnerName = "",
|
||||
chipCount = 0,
|
||||
onStartGame,
|
||||
onCancelGame,
|
||||
onGoHome,
|
||||
}) {
|
||||
const { language, t } = useLanguage();
|
||||
const cur = games.find((x) => x.id === gameId);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [startError, setStartError] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
const renderMemberName = (m) => {
|
||||
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
|
||||
@@ -67,6 +70,21 @@ export default function GamePickerCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelGame = async () => {
|
||||
if (!onCancelGame || cancelling) return;
|
||||
const confirmed = window.confirm(language === "en" ? "Cancel and delete this game? All game data will be removed." : "Dieses Spiel wirklich abbrechen und löschen? Alle Spieldaten werden entfernt.");
|
||||
if (!confirmed) return;
|
||||
setCancelling(true);
|
||||
setStartError("");
|
||||
try {
|
||||
await onCancelGame();
|
||||
} catch (e) {
|
||||
setStartError(e?.message || (language === "en" ? "The game could not be cancelled." : "Das Spiel konnte nicht abgebrochen werden."));
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={styles.card}>
|
||||
@@ -85,8 +103,11 @@ export default function GamePickerCard({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
|
||||
{t("help")}
|
||||
<button onClick={onGoHome} style={styles.helpBtn} title={language === "en" ? "Home" : "Startseite"} aria-label={language === "en" ? "Home" : "Startseite"}>
|
||||
🏠
|
||||
</button>
|
||||
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")} aria-label={t("help")}>
|
||||
❔
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +190,9 @@ export default function GamePickerCard({
|
||||
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
|
||||
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
|
||||
</div>
|
||||
<button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, width: "100%", marginTop: 9, color: "#ffb3b3" }} disabled={cancelling}>
|
||||
{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
|
||||
@@ -179,11 +203,14 @@ export default function GamePickerCard({
|
||||
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
|
||||
{finished
|
||||
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
|
||||
: `✓ ${t("started")} · ${chipCount} ${t("chipsCreated")}`}
|
||||
</div>
|
||||
{isHost && !finished && <button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, margin: "0 12px 12px", width: "calc(100% - 24px)", color: "#ffb3b3" }} disabled={cancelling}>{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}</button>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spieler */}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
import { useLanguage } from "../i18n";
|
||||
|
||||
export default function HomePage({ games = [], onOpenNewGame, onOpenGame }) {
|
||||
const { language, t } = useLanguage();
|
||||
return (
|
||||
<main className="hp-home-page" style={{ marginTop: 14, display: "grid", gap: 14 }}>
|
||||
<div style={styles.card}>
|
||||
<div style={{ padding: "26px 20px 22px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: 34, color: stylesTokens.textGold }}>✦</div>
|
||||
<h1 style={{ margin: "8px 0 0", color: stylesTokens.textGold, fontSize: 24 }}>{language === "en" ? "Welcome to Notizbogen" : "Willkommen beim Notizbogen"}</h1>
|
||||
<p style={{ margin: "9px auto 0", maxWidth: 430, color: stylesTokens.textDim, lineHeight: 1.5 }}>{language === "en" ? "Create a new investigation or join an existing game to get started." : "Erstelle eine neue Ermittlung oder tritt einem bestehenden Spiel bei, um zu beginnen."}</p>
|
||||
<button onClick={onOpenNewGame} style={{ ...styles.primaryBtn, marginTop: 18 }}>{language === "en" ? "✦ Create or join game" : "✦ Spiel erstellen oder beitreten"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{games.length > 0 && <div style={styles.card}>
|
||||
<div style={styles.sectionHeader}>{language === "en" ? "Your games" : "Deine Spiele"}</div>
|
||||
<div style={{ display: "grid", gap: 8, padding: 12 }}>
|
||||
{games.map((game) => <button key={game.id} onClick={() => onOpenGame(game.id)} style={{ ...styles.secondaryBtn, display: "flex", justifyContent: "space-between", alignItems: "center", textAlign: "left" }}>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{game.name}</span><span style={{ color: stylesTokens.textGold, marginLeft: 10 }}>{game.code}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
import { useLanguage } from "../i18n";
|
||||
@@ -21,6 +22,11 @@ export default function NewGameModal({
|
||||
const [err, setErr] = useState("");
|
||||
const [created, setCreated] = useState(null); // { code }
|
||||
const [toast, setToast] = useState("");
|
||||
const [qrOpen, setQrOpen] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
|
||||
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
|
||||
|
||||
@@ -32,6 +38,9 @@ export default function NewGameModal({
|
||||
setToast("");
|
||||
setJoinCode("");
|
||||
setCreated(null);
|
||||
setQrOpen(false);
|
||||
setQrDataUrl("");
|
||||
setScannerOpen(false);
|
||||
|
||||
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
|
||||
if (hasGame && !gameFinished) {
|
||||
@@ -41,6 +50,57 @@ export default function NewGameModal({
|
||||
}
|
||||
}, [open, hasGame, gameFinished]);
|
||||
|
||||
useEffect(() => {
|
||||
const qrValue = created?.code || currentCode;
|
||||
if (!qrOpen || !qrValue) return;
|
||||
QRCode.toDataURL(qrValue, { width: 280, margin: 2, errorCorrectionLevel: "M", color: { dark: "#17161b", light: "#f5efdc" } })
|
||||
.then(setQrDataUrl).catch(() => setQrDataUrl(""));
|
||||
}, [qrOpen, created?.code, currentCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scannerOpen) return undefined;
|
||||
let alive = true;
|
||||
let intervalId;
|
||||
const startScanner = async () => {
|
||||
if (!("BarcodeDetector" in window) || !navigator.mediaDevices?.getUserMedia) {
|
||||
setErr(language === "en" ? "QR scanning is not supported here. Enter the code manually." : "QR-Scannen wird hier nicht unterstützt. Bitte Code manuell eingeben.");
|
||||
setScannerOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false });
|
||||
if (!alive) { stream.getTracks().forEach((track) => track.stop()); return; }
|
||||
streamRef.current = stream;
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
intervalId = window.setInterval(async () => {
|
||||
if (!alive || !videoRef.current) return;
|
||||
try {
|
||||
const codes = await detector.detect(videoRef.current);
|
||||
const raw = codes?.[0]?.rawValue?.trim() || "";
|
||||
if (raw) {
|
||||
setJoinCode((raw.match(/[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4,}/i)?.[0] || raw).toUpperCase());
|
||||
setScannerOpen(false);
|
||||
setToast(language === "en" ? "✅ QR code recognized" : "✅ QR-Code erkannt");
|
||||
}
|
||||
} catch { /* frame not ready */ }
|
||||
}, 350);
|
||||
} catch {
|
||||
setErr(language === "en" ? "Camera access was denied. Enter the code manually." : "Kamerazugriff wurde verweigert. Bitte Code manuell eingeben.");
|
||||
setScannerOpen(false);
|
||||
}
|
||||
};
|
||||
startScanner();
|
||||
return () => {
|
||||
alive = false;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
if (videoRef.current) videoRef.current.srcObject = null;
|
||||
};
|
||||
}, [scannerOpen, language]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const showToast = (msg) => {
|
||||
@@ -156,6 +216,13 @@ export default function NewGameModal({
|
||||
>
|
||||
⧉ {t("copy")} {language === "en" ? "code" : "Code"}
|
||||
</button>
|
||||
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn} disabled={!codeToShow}>
|
||||
▣ {qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
|
||||
</button>
|
||||
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
|
||||
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
|
||||
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||
@@ -202,6 +269,15 @@ export default function NewGameModal({
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<button type="button" onClick={() => { setErr(""); setScannerOpen((value) => !value); }} style={styles.secondaryBtn}>
|
||||
▣ {scannerOpen ? (language === "en" ? "Close scanner" : "Scanner schließen") : (language === "en" ? "Scan QR code" : "QR-Code scannen")}
|
||||
</button>
|
||||
|
||||
{scannerOpen && <div style={{ display: "grid", gap: 8 }}>
|
||||
<video ref={videoRef} muted playsInline style={{ width: "100%", maxHeight: 240, objectFit: "cover", borderRadius: 14, background: "#050507", border: `1px solid ${stylesTokens.panelBorder}` }} />
|
||||
<div style={{ color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>{language === "en" ? "Point your camera at the game QR code." : "Richte die Kamera auf den QR-Code des Spiels."}</div>
|
||||
</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
|
||||
{language === "en" ? "Back" : "Zurück"}
|
||||
@@ -249,6 +325,13 @@ export default function NewGameModal({
|
||||
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
|
||||
⧉ {t("copy")} {language === "en" ? "code" : "Code"}
|
||||
</button>
|
||||
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn}>
|
||||
▣ {qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
|
||||
</button>
|
||||
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
|
||||
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
|
||||
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
import { useLanguage } from "../i18n";
|
||||
import { translateEntryLabel, useLanguage } from "../i18n";
|
||||
|
||||
export default function SheetSection({
|
||||
title,
|
||||
@@ -13,7 +13,7 @@ export default function SheetSection({
|
||||
displayTag,
|
||||
readOnly = false,
|
||||
}) {
|
||||
const { t } = useLanguage();
|
||||
const { language, t } = useLanguage();
|
||||
const getRowBg = (status) => {
|
||||
if (status === 1) return stylesTokens.rowNoBg;
|
||||
if (status === 2) return stylesTokens.rowOkBg;
|
||||
@@ -83,7 +83,7 @@ export default function SheetSection({
|
||||
}}
|
||||
title={readOnly ? t("readOnly") : t("cycleStatus")}
|
||||
>
|
||||
{e.label}
|
||||
{translateEntryLabel(e.label, language)}
|
||||
</div>
|
||||
|
||||
<div style={styles.statusCell}>
|
||||
|
||||
@@ -18,48 +18,39 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
if (!reduceMotion) {
|
||||
const end = Date.now() + 4500;
|
||||
|
||||
// WICHTIG: über dem Overlay rendern
|
||||
const TOP_Z = 2147483647;
|
||||
|
||||
// hellere Farben damit’s auch auf dark overlay knallt
|
||||
const bright = ["#ffffff", "#ffd166", "#06d6a0", "#4cc9f0", "#f72585"];
|
||||
const bright = ["#ffffff", "#e9d8a6", "#c9aa62", "#7cffa8"];
|
||||
|
||||
// 2 große Bursts
|
||||
confetti({
|
||||
particleCount: 170,
|
||||
spread: 95,
|
||||
startVelocity: 42,
|
||||
origin: { x: 0.12, y: 0.62 },
|
||||
particleCount: 55,
|
||||
spread: 72,
|
||||
startVelocity: 28,
|
||||
gravity: 0.85,
|
||||
ticks: 150,
|
||||
scalar: 0.82,
|
||||
origin: { x: 0.22, y: 0.62 },
|
||||
zIndex: TOP_Z,
|
||||
colors: bright,
|
||||
});
|
||||
confetti({
|
||||
particleCount: 170,
|
||||
spread: 95,
|
||||
startVelocity: 42,
|
||||
origin: { x: 0.88, y: 0.62 },
|
||||
particleCount: 55,
|
||||
spread: 72,
|
||||
startVelocity: 28,
|
||||
gravity: 0.85,
|
||||
ticks: 150,
|
||||
scalar: 0.82,
|
||||
origin: { x: 0.78, y: 0.62 },
|
||||
zIndex: TOP_Z,
|
||||
colors: bright,
|
||||
});
|
||||
|
||||
// “Rain” über die Zeit
|
||||
(function frame() {
|
||||
confetti({
|
||||
particleCount: 8,
|
||||
spread: 75,
|
||||
startVelocity: 34,
|
||||
origin: { x: Math.random(), y: Math.random() * 0.18 },
|
||||
scalar: 1.05,
|
||||
zIndex: TOP_Z,
|
||||
colors: bright,
|
||||
});
|
||||
if (Date.now() < end) requestAnimationFrame(frame);
|
||||
})();
|
||||
}
|
||||
|
||||
const t = setTimeout(() => onClose?.(), 5500);
|
||||
const t = setTimeout(() => onClose?.(), 4200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
|
||||
@@ -3,6 +3,28 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from "
|
||||
const STORAGE_KEY = "hpLanguage";
|
||||
const LanguageContext = createContext(null);
|
||||
|
||||
const entryLabels = {
|
||||
"Schlaftrunk": "Sleeping Potion",
|
||||
"Verschwindekabinett": "Vanishing Cabinet",
|
||||
"Portschlüssel": "Portkey",
|
||||
"Impedimenta": "Impedimenta",
|
||||
"Petrificus Totalus": "Petrificus Totalus",
|
||||
"Alraune": "Mandrake",
|
||||
"Große Halle": "Great Hall",
|
||||
"Krankenflügel": "Hospital Wing",
|
||||
"Raum der Wünsche": "Room of Requirement",
|
||||
"Klassenzimmer für Zaubertränke": "Potions Classroom",
|
||||
"Pokalszimmer": "Trophy Room",
|
||||
"Klassenzimmer für Wahrsagen": "Divination Classroom",
|
||||
"Eulerei": "Owlery",
|
||||
"Bibliothek": "Library",
|
||||
"Verteidigung gegen die dunklen Künste": "Defence Against the Dark Arts",
|
||||
};
|
||||
|
||||
export function translateEntryLabel(label, language) {
|
||||
return language === "en" ? (entryLabels[label] || label) : label;
|
||||
}
|
||||
|
||||
export const translations = {
|
||||
de: {
|
||||
language: "Deutsch",
|
||||
|
||||
@@ -105,10 +105,10 @@ export function useHpGlobalStyles() {
|
||||
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
|
||||
.hp-topbar-actions { margin-left: auto; }
|
||||
.hp-user-menu-wrap { min-width: 0; }
|
||||
.hp-admin-user-row { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.45fr) 62px 62px 78px; white-space: nowrap; }
|
||||
.hp-admin-user-row { grid-template-columns: minmax(90px, 1.2fr) minmax(90px, 1.5fr) 58px 58px 100px; white-space: nowrap; min-height: 42px; padding: 6px 8px !important; }
|
||||
.hp-admin-action { min-width: 0; }
|
||||
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
|
||||
.hp-admin-icon-action { width: 34px; height: 34px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; }
|
||||
.hp-admin-icon-action { width: 30px; height: 30px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; }
|
||||
.hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; }
|
||||
.hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
|
||||
@@ -134,13 +134,11 @@ export function useHpGlobalStyles() {
|
||||
.hp-topbar-user { justify-content: center; }
|
||||
.hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
|
||||
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hp-admin-user-row { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 11px !important; border-radius: 15px !important; }
|
||||
.hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; }
|
||||
.hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; }
|
||||
.hp-admin-role { grid-column: 2; grid-row: 1; }
|
||||
.hp-admin-status { grid-column: 2; grid-row: 2; }
|
||||
.hp-admin-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; }
|
||||
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
|
||||
.hp-admin-user-row { grid-template-columns: minmax(65px, 1.1fr) minmax(0, 1.3fr) 42px 42px 94px; gap: 3px; padding: 7px !important; border-radius: 12px !important; min-height: 42px; }
|
||||
.hp-admin-name, .hp-admin-email { min-width: 0; font-size: 12px !important; }
|
||||
.hp-admin-role, .hp-admin-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px !important; padding-left: 2px !important; padding-right: 2px !important; }
|
||||
.hp-admin-actions { width: auto; justify-content: flex-end; gap: 3px; }
|
||||
.hp-admin-actions .hp-admin-action { width: 29px; min-width: 29px; height: 29px; min-height: 29px; padding: 0 !important; font-size: 14px; }
|
||||
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; }
|
||||
.hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; }
|
||||
.hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
|
||||
|
||||
@@ -189,7 +189,6 @@ export const styles = {
|
||||
},
|
||||
userRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 80px 90px",
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
borderRadius: 12,
|
||||
|
||||
Reference in New Issue
Block a user