Compare commits

..
2 Commits
Author SHA1 Message Date
nessi 33a972e502 Add internationalization support with English/German language toggle across all components
Integrated i18n system throughout the application with `useLanguage` hook providing translation function and language state. Added bilingual support to all user-facing strings in App.jsx (password validation, error messages, game creation, sheet sections), AdminPanel (user management, form validation, status messages), AdminSettingsModal (SMTP configuration), ChipModal, DesignModal, GamePickerCard (lobby interface,
2026-08-02 10:33:40 +02:00
nessi d96b75ca82 Add email invitation system with SMTP configuration and invite token workflow
Implemented comprehensive email invitation functionality for new users. Added SMTP settings management in admin panel with configuration UI for host, port, credentials, sender details, and TLS options. Created invite token system with 48-hour expiration and single-use enforcement. Added mailer module with HTML email templates in German theme styling (radial gradient backgrounds, gold accents, bordered cards). Implemented invite
2026-08-02 10:24:34 +02:00
27 changed files with 861 additions and 180 deletions
+63
View File
@@ -0,0 +1,63 @@
import html
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr
from .models import AppSettings, User
def _settings_ready(settings: AppSettings | None) -> bool:
return bool(settings and settings.smtp_host and settings.smtp_from_email)
def send_html_email(settings: AppSettings, recipient: str, subject: str, body_html: str, body_text: str):
if not _settings_ready(settings):
raise ValueError("SMTP settings are incomplete")
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((settings.smtp_from_name or "Cluedo HP", settings.smtp_from_email))
message["To"] = recipient
message.set_content(body_text)
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:
server.ehlo()
if settings.smtp_use_tls:
server.starttls(context=context)
server.ehlo()
if settings.smtp_username:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def send_user_invite(settings: AppSettings, user: User, invite_url: str):
name = html.escape(user.display_name or user.email)
safe_url = html.escape(invite_url, quote=True)
from_name = html.escape(settings.smtp_from_name or "Cluedo HP")
body_html = f"""
<!doctype html>
<html><body style="margin:0;background:#0b0b0f;color:#f5efdc;font-family:Georgia,serif;">
<div style="padding:36px 16px;background:radial-gradient(circle at top,#312b24 0,#0b0b0f 58%);">
<div style="max-width:580px;margin:0 auto;border:1px solid #6f603f;border-radius:22px;overflow:hidden;background:#17161b;box-shadow:0 18px 55px rgba(0,0,0,.55);">
<div style="padding:28px 30px;text-align:center;background:linear-gradient(135deg,#29231c,#141319);border-bottom:1px solid #6f603f;">
<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>
<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>
</div>
<div style="padding:16px 30px;color:#8e8777;font-size:12px;border-top:1px solid #302c2b;text-align:center;">Deine Notizen bleiben privat.</div>
</div>
</div>
</body></html>
"""
body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig."
send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text)
+2
View File
@@ -12,6 +12,7 @@ from .routes.auth import router as auth_router
from .routes.admin import router as admin_router from .routes.admin import router as admin_router
from .routes.games import router as games_router from .routes.games import router as games_router
from .routes.setup import router as setup_router from .routes.setup import router as setup_router
from .routes.invites import router as invites_router
app = FastAPI(title="Cluedo Sheet") app = FastAPI(title="Cluedo Sheet")
@@ -31,6 +32,7 @@ app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_router) app.include_router(games_router)
app.include_router(setup_router) app.include_router(setup_router)
app.include_router(invites_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
+25
View File
@@ -41,6 +41,31 @@ class User(Base):
display_name: Mapped[str] = mapped_column(String, default="") display_name: Mapped[str] = mapped_column(String, default="")
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[str] = mapped_column(String, primary_key=True, default="default")
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="")
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)
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
class InviteToken(Base):
__tablename__ = "invite_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
token_hash: Mapped[str] = mapped_column(String, index=True)
expires_at: Mapped[str] = mapped_column(DateTime(timezone=True))
used_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
+111 -6
View File
@@ -1,7 +1,12 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User, Role from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, InviteToken, User, Role
from ..security import hash_password, get_session_user_id from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -37,18 +42,47 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
password = data.get("password") or "" password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip() display_name = (data.get("display_name") or "").strip()
if not email or not password: if not email or not display_name:
raise HTTPException(400, "email/password required") raise HTTPException(400, "name/email required")
if db.query(User).filter(User.email == email).first(): if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists") raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value role = data.get("role")
if not role:
raise HTTPException(400, "role required")
if role not in (Role.admin.value, Role.user.value): if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role") raise HTTPException(400, "invalid role")
settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default")
invite_sent = not password
raw_token = None
if invite_sent:
if not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(400, "configure SMTP settings before sending invites")
raw_token = secrets.token_urlsafe(32)
password = secrets.token_urlsafe(32)
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name) u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
db.add(u); db.commit() db.add(u)
return {"ok": True, "id": u.id} db.flush()
if invite_sent:
invite = InviteToken(
user_id=u.id,
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + timedelta(hours=48),
)
db.add(invite)
invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}"
try:
send_user_invite(settings, u, invite_url)
except Exception as exc:
db.rollback()
raise HTTPException(502, f"invite email could not be sent: {exc}")
db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
@router.delete("/users/{user_id}") @router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
@@ -106,3 +140,74 @@ def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(ge
db.add(user) db.add(user)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
def get_settings(db: Session) -> AppSettings:
settings = db.query(AppSettings).filter(AppSettings.id == "default").first()
if not settings:
settings = AppSettings(id="default")
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/settings/smtp")
def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
return {
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password_configured": bool(settings.smtp_password),
"smtp_from_email": settings.smtp_from_email,
"smtp_from_name": settings.smtp_from_name,
"smtp_use_tls": settings.smtp_use_tls,
"app_base_url": settings.app_base_url,
}
@router.patch("/settings/smtp")
def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
settings.smtp_host = (data.get("smtp_host") or "").strip()
try:
settings.smtp_port = int(data.get("smtp_port") or 587)
except (TypeError, ValueError):
raise HTTPException(400, "SMTP port must be a number")
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))
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"]
if settings.smtp_host and not settings.smtp_from_email:
raise HTTPException(400, "from email required when SMTP is configured")
db.add(settings)
db.commit()
return {"ok": True}
@router.post("/settings/smtp/test")
def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
recipient = (data.get("recipient") or "").strip()
if not recipient:
raise HTTPException(400, "recipient required")
try:
send_html_email(
settings,
recipient,
"SMTP-Test Cluedo HP",
"<div style='font-family:Georgia,serif;padding:24px;background:#17161b;color:#f5efdc'><h2 style='color:#e9d8a6'>SMTP funktioniert</h2><p>Der Einladungsversand ist bereit.</p></div>",
"SMTP funktioniert. Der Einladungsversand ist bereit.",
)
except Exception as exc:
raise HTTPException(502, f"SMTP test failed: {exc}")
return {"ok": True}
+47
View File
@@ -0,0 +1,47 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import InviteToken, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/auth/invite", tags=["invites"])
def get_invite(token: str, db: Session):
token_hash = hashlib.sha256(token.encode()).hexdigest()
invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first()
expires_at = invite.expires_at if invite else None
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc):
raise HTTPException(410, "invite is invalid or expired")
user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first()
if not user:
raise HTTPException(410, "invite is invalid or expired")
return invite, user
@router.get("/{token}")
def invite_info(token: str, db: Session = Depends(get_db)):
_invite, user = get_invite(token, db)
return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role}
@router.post("/{token}")
def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)):
invite, user = get_invite(token, db)
password = data.get("password") or ""
if len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
user.password_hash = hash_password(password)
invite.used_at = datetime.now(timezone.utc)
db.add(user)
db.add(invite)
db.commit()
set_session(resp, make_session_value(user.id))
return {"ok": True}
+27 -10
View File
@@ -25,9 +25,13 @@ import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge"; import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal"; import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal"; import StatsModal from "./components/StatsModal";
import AdminSettingsModal from "./components/AdminSettingsModal";
import InvitePage from "./components/InvitePage";
import { useLanguage } from "./i18n";
export default function App() { export default function App() {
useHpGlobalStyles(); useHpGlobalStyles();
const { language, t } = useLanguage();
// Auth/Login UI state // Auth/Login UI state
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
@@ -62,6 +66,7 @@ export default function App() {
const [chipEntry, setChipEntry] = useState(null); const [chipEntry, setChipEntry] = useState(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const [adminOpen, setAdminOpen] = useState(false); const [adminOpen, setAdminOpen] = useState(false);
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false);
const [pw1, setPw1] = useState(""); const [pw1, setPw1] = useState("");
@@ -94,6 +99,7 @@ export default function App() {
const [celebrateOpen, setCelebrateOpen] = useState(false); const [celebrateOpen, setCelebrateOpen] = useState(false);
const [celebrateName, setCelebrateName] = useState(""); const [celebrateName, setCelebrateName] = useState("");
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false); const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
const inviteToken = window.location.pathname.startsWith("/invite/") ? decodeURIComponent(window.location.pathname.slice("/invite/".length)) : "";
// baseline per game: beim ersten Meta-Load NICHT feiern // baseline per game: beim ersten Meta-Load NICHT feiern
const winnerBaselineRef = useRef(false); const winnerBaselineRef = useRef(false);
@@ -393,8 +399,8 @@ export default function App() {
const savePassword = async () => { const savePassword = async () => {
setPwMsg(""); setPwMsg("");
if (!pw1 || pw1.length < 8) return setPwMsg("Passwort muss mindestens 8 Zeichen haben."); if (!pw1 || pw1.length < 8) return setPwMsg(`${language === "en" ? "Password must be at least 8 characters." : "Passwort muss mindestens 8 Zeichen haben."}`);
if (pw1 !== pw2) return setPwMsg("Passwörter stimmen nicht überein."); if (pw1 !== pw2) return setPwMsg(`${language === "en" ? "Passwords do not match." : "Passwörter stimmen nicht überein."}`);
setPwSaving(true); setPwSaving(true);
try { try {
@@ -402,10 +408,10 @@ export default function App() {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ password: pw1 }), body: JSON.stringify({ password: pw1 }),
}); });
setPwMsg("Passwort gespeichert."); setPwMsg(`${language === "en" ? "Password saved." : "Passwort gespeichert."}`);
setTimeout(() => closePwModal(), 650); setTimeout(() => closePwModal(), 650);
} catch (e) { } catch (e) {
setPwMsg("❌ Fehler: " + (e?.message || "unknown")); setPwMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally { } finally {
setPwSaving(false); setPwSaving(false);
} }
@@ -452,7 +458,7 @@ export default function App() {
setStats(s); setStats(s);
} catch (e) { } catch (e) {
setStats(null); setStats(null);
setStatsError("❌ Fehler: " + (e?.message || "unknown")); setStatsError("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally { } finally {
setStatsLoading(false); setStatsLoading(false);
} }
@@ -488,7 +494,7 @@ export default function App() {
const g = await api("/games", { const g = await api("/games", {
method: "POST", method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), body: JSON.stringify({ name: (language === "en" ? "Game " : "Spiel ") + new Date().toLocaleString(language === "en" ? "en-US" : "de-DE") }),
}); });
const gs = await api("/games"); const gs = await api("/games");
@@ -553,7 +559,7 @@ export default function App() {
if (next === "s") { if (next === "s") {
if (!gameMeta?.started || gameChips.length === 0) { if (!gameMeta?.started || gameChips.length === 0) {
showSnack("Das Spiel muss zuerst gestartet werden."); showSnack(language === "en" ? "The game must be started first." : "Das Spiel muss zuerst gestartet werden.");
return; return;
} }
setChipEntry(entry); setChipEntry(entry);
@@ -628,6 +634,10 @@ export default function App() {
}; };
// ===== Login page ===== // ===== Login page =====
if (inviteToken) {
return <InvitePage token={inviteToken} />;
}
if (!me) { if (!me) {
return ( return (
<LoginPage <LoginPage
@@ -656,9 +666,9 @@ export default function App() {
const sections = sheet const sections = sheet
? [ ? [
{ key: "suspect", title: "VERDÄCHTIGE PERSON", entries: sheet.suspect || [] }, { key: "suspect", title: t("suspects").toUpperCase(), entries: sheet.suspect || [] },
{ key: "item", title: "GEGENSTAND", entries: sheet.item || [] }, { key: "item", title: t("items").toUpperCase(), entries: sheet.item || [] },
{ key: "location", title: "ORT", entries: sheet.location || [] }, { key: "location", title: t("locations").toUpperCase(), entries: sheet.location || [] },
] ]
: []; : [];
@@ -694,6 +704,10 @@ export default function App() {
setAdminOpen(true); setAdminOpen(true);
setUserMenuOpen(false); setUserMenuOpen(false);
}} }}
openAdminSettings={() => {
setAdminSettingsOpen(true);
setUserMenuOpen(false);
}}
doLogout={doLogout} doLogout={doLogout}
onOpenNewGame={() => setNewGameOpen(true)} onOpenNewGame={() => setNewGameOpen(true)}
/> />
@@ -701,6 +715,9 @@ export default function App() {
{me.role === "admin" && ( {me.role === "admin" && (
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} /> <AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
)} )}
{me.role === "admin" && (
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
)}
<GamePickerCard <GamePickerCard
games={games} games={games}
+29 -21
View File
@@ -3,15 +3,18 @@ import { createPortal } from "react-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false }; const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false };
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) { export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const { language, t } = useLanguage();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null); const [editingUser, setEditingUser] = useState(null);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -38,6 +41,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
setEditingUser(null); setEditingUser(null);
setForm({ ...emptyForm }); setForm({ ...emptyForm });
setMsg(""); setMsg("");
setNotice("");
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -51,6 +55,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
disabled: !!user.disabled, disabled: !!user.disabled,
}); });
setMsg(""); setMsg("");
setNotice("");
setEditorOpen(true); setEditorOpen(true);
}; };
@@ -62,9 +67,9 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
const saveUser = async () => { const saveUser = async () => {
setMsg(""); setMsg("");
if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich."); if (!form.email.trim()) return setMsg(`${t("email")} ${language === "en" ? "is required." : "ist erforderlich."}`);
if (!editingUser && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben."); if (!editingUser && form.password && form.password.length < 8) return setMsg(`${t("password")} ${language === "en" ? "must be at least 8 characters." : "muss mindestens 8 Zeichen haben."}`);
if (editingUser && form.password && form.password.length < 8) return setMsg("Neues Passwort muss mindestens 8 Zeichen haben."); if (editingUser && form.password && form.password.length < 8) return setMsg(`${language === "en" ? "New password must be at least 8 characters." : "Neues Passwort muss mindestens 8 Zeichen haben."}`);
setSaving(true); setSaving(true);
try { try {
@@ -76,14 +81,15 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
}; };
if (form.password) payload.password = form.password; if (form.password) payload.password = form.password;
await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", { const result = await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
method: editingUser ? "PATCH" : "POST", method: editingUser ? "PATCH" : "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
await loadUsers(); await loadUsers();
setNotice(result.invite_sent ? (language === "en" ? "✅ Invitation sent by email." : "✅ Einladung wurde per E-Mail versendet.") : (language === "en" ? "✅ User saved." : "✅ User gespeichert."));
closeEditor(); closeEditor();
} catch (e) { } catch (e) {
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen.")); setMsg("❌ " + (e?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen.")));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -96,7 +102,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
await api(`/admin/users/${user.id}`, { method: "DELETE" }); await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
alert("Fehler: " + (e?.message || "unknown")); alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
@@ -108,16 +114,17 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div style={{ padding: "18px 18px 16px" }}> <div style={{ padding: "18px 18px 16px" }}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<div> <div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Dashboard</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminDashboard")}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>Benutzer, Rollen und Zugangsdaten verwalten</div> <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{t("adminDashboardSubtitle")}</div>
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button> <button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div> </div>
<div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}> <div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>Vorhandene User <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div> <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{t("existingUsers")} <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div>
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button> <button onClick={openCreate} style={styles.primaryBtn}>+ {t("createUser")}</button>
</div> </div>
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
<div style={{ marginTop: 10, display: "grid", gap: 9 }}> <div style={{ marginTop: 10, display: "grid", gap: 9 }}>
{users.map((user) => ( {users.map((user) => (
@@ -127,8 +134,8 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div> <div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div>
<div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div> <div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
<div className="hp-admin-actions"> <div className="hp-admin-actions">
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title="User bearbeiten" aria-label={`${user.display_name || user.email} bearbeiten`}></button> <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 ? "User ist deaktiviert" : "User deaktivieren"} aria-label={`${user.display_name || user.email} deaktivieren`}></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>
</div> </div>
</div> </div>
))} ))}
@@ -141,20 +148,21 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div className="hp-admin-editor-card" style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}> <div className="hp-admin-editor-card" style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<div> <div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? "User bearbeiten" : "Neuen User anlegen"}</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? t("editUser") : t("newUser")}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}</div> <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? (language === "en" ? "Update profile and credentials" : "Profil und Zugangsdaten aktualisieren") : (language === "en" ? "Create a new user account" : "Ein neues Benutzerkonto erstellen")}</div>
</div> </div>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label="Schließen"></button> <button onClick={closeEditor} style={styles.modalCloseBtn} aria-label={t("close")}></button>
</div> </div>
<div style={{ marginTop: 18, display: "grid", gap: 10 }}> <div style={{ marginTop: 18, display: "grid", gap: 10 }}>
<label style={styles.adminFieldLabel}>Anzeigename<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder="z. B. Sascha Nesterovic" style={styles.input} autoFocus /></label> <label style={styles.adminFieldLabel}>{t("displayName")}<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder={language === "en" ? "e.g. Sascha Nesterovic" : "z. B. Sascha Nesterovic"} style={styles.input} autoFocus /></label>
<label style={styles.adminFieldLabel}>E-Mail<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label> <label style={styles.adminFieldLabel}>{t("email")}<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label>
<label style={styles.adminFieldLabel}>{editingUser ? "Neues Passwort (optional)" : "Passwort"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Mindestens 8 Zeichen"} type="password" style={styles.input} /></label> <label style={styles.adminFieldLabel}>{editingUser ? `${t("password")} (${t("optional")})` : `${t("password")} (${t("optional")})`}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? (language === "en" ? "Leave blank = unchanged" : "Leer lassen = unverändert") : t("inviteHint")} type="password" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>Rolle<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">User</option><option value="admin">Admin</option></select></label> <label style={styles.adminFieldLabel}>{t("role")}<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">{t("user")}</option><option value="admin">{t("admin")}</option></select></label>
{editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> Konto ist aktiv</label>} {editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> {t("accountActive")}</label>}
{msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>} {msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>}
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? "Speichern …" : editingUser ? "Änderungen speichern" : "User erstellen"}</button> {!editingUser && <div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{t("inviteNotice")}</div>}
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? (language === "en" ? "Saving …" : "Speichern …") : editingUser ? (language === "en" ? "Save changes" : "Änderungen speichern") : t("createUser")}</button>
</div> </div>
</div> </div>
</div>, </div>,
@@ -0,0 +1,86 @@
import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
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,
app_base_url: window.location.origin,
};
export default function AdminSettingsModal({ open, onClose }) {
const { language, t } = useLanguage();
const [form, setForm] = useState(initial);
const [configured, setConfigured] = useState(false);
const [recipient, setRecipient] = useState("");
const [message, setMessage] = useState("");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
if (!open) return;
setMessage("");
api("/admin/settings/smtp").then((data) => {
setForm({ ...initial, ...data, smtp_password: "" });
setConfigured(!!data.smtp_password_configured);
}).catch((error) => setMessage("❌ " + (error?.message || (language === "en" ? "Loading failed." : "Laden fehlgeschlagen."))));
}, [open]);
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true); setMessage("");
try {
await api("/admin/settings/smtp", { method: "PATCH", body: JSON.stringify(form) });
setConfigured(!!form.smtp_password || configured);
setMessage("✅ SMTP-Einstellungen gespeichert.");
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen."))); }
finally { setSaving(false); }
};
const test = async () => {
if (!recipient.trim()) return setMessage(`${language === "en" ? "Please enter a test email." : "Bitte eine Test-E-Mail angeben."}`);
setTesting(true); setMessage("");
try {
await api("/admin/settings/smtp/test", { method: "POST", body: JSON.stringify({ recipient }) });
setMessage("✅ Test-E-Mail wurde versendet.");
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Test failed." : "Test fehlgeschlagen."))); }
finally { setTesting(false); }
};
if (!open) return null;
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div className="hp-admin-settings-card" style={{ ...styles.modalCard, width: "min(620px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div><div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminSettings")}</div><div style={{ color: stylesTokens.textDim, fontSize: 12, marginTop: 3 }}>{t("adminSettingsSubtitle")}</div></div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}></button>
</div>
<div style={{ marginTop: 16, display: "grid", gap: 10 }}>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("smtpServer")}<input value={form.smtp_host} onChange={(e) => setField("smtp_host", e.target.value)} placeholder="smtp.example.com" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("port")}<input type="number" value={form.smtp_port} onChange={(e) => setField("smtp_port", e.target.value)} style={styles.input} /></label>
</div>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("smtpUsername")}<input value={form.smtp_username} onChange={(e) => setField("smtp_username", e.target.value)} placeholder={t("optional")} style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("password")}<input type="password" value={form.smtp_password} onChange={(e) => setField("smtp_password", e.target.value)} placeholder={configured ? "••••••••" : t("optional")} style={styles.input} /></label>
</div>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("senderEmail")}<input type="email" value={form.smtp_from_email} onChange={(e) => setField("smtp_from_email", e.target.value)} placeholder="noreply@example.com" style={styles.input} /></label>
<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>
<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>
</div>
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
<button onClick={save} style={{ ...styles.primaryBtn, width: "100%" }} disabled={saving}>{saving ? (language === "en" ? "Saving …" : "Speichern …") : t("saveSettings")}</button>
</div>
</div>
</div>, document.body
);
}
+7 -5
View File
@@ -1,21 +1,23 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
const { language, t } = useLanguage();
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}> <div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<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 }}>Wer hat die Karte?</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("whoHasCard")}</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>{t("selectChip")}:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{chips.map((item) => ( {chips.map((item) => (
@@ -27,12 +29,12 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip,
{!chips.length && ( {!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}> <div style={{ marginTop: 12, color: stylesTokens.textDim }}>
Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden. {language === "en" ? "The game has not started yet or no player chips exist." : "Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden."}
</div> </div>
)} )}
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}> <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 . {language === "en" ? <>Tip: Tap the note button again to go from <b>s</b> back to .</> : <>Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .</>}
</div> </div>
</div> </div>
</div> </div>
+5 -3
View File
@@ -2,8 +2,10 @@ import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { THEMES } from "../styles/themes"; import { THEMES } from "../styles/themes";
import { useLanguage } from "../i18n";
export default function DesignModal({ open, onClose, themeKey, onSelect }) { export default function DesignModal({ open, onClose, themeKey, onSelect }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const themeEntries = Object.entries(THEMES); const themeEntries = Object.entries(THEMES);
@@ -12,14 +14,14 @@ export default function DesignModal({ open, onClose, themeKey, onSelect }) {
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<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 }}>Design ändern</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("changeDesign")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}>
Wähle dein Theme: {language === "en" ? "Choose your theme:" : "Wähle dein Theme:"}
</div> </div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}> <div style={{ marginTop: 12, display: "grid", gap: 10 }}>
+18 -16
View File
@@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GamePickerCard({ export default function GamePickerCard({
games, games,
@@ -17,6 +18,7 @@ export default function GamePickerCard({
chipCount = 0, chipCount = 0,
onStartGame, onStartGame,
}) { }) {
const { language, t } = useLanguage();
const cur = games.find((x) => x.id === gameId); const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState(""); const [startError, setStartError] = useState("");
@@ -52,14 +54,14 @@ export default function GamePickerCard({
const handleStartGame = async () => { const handleStartGame = async () => {
if (!onStartGame || starting || members.length < 2) return; if (!onStartGame || starting || members.length < 2) return;
if (!window.confirm("Spiel jetzt starten? Danach können keine weiteren Spieler beitreten.")) return; if (!window.confirm(language === "en" ? "Start the game now? No more players can join afterwards." : "Spiel jetzt starten? Danach können keine weiteren Spieler beitreten.")) return;
setStarting(true); setStarting(true);
setStartError(""); setStartError("");
try { try {
await onStartGame(); await onStartGame();
} catch (e) { } catch (e) {
setStartError(e?.message || "Das Spiel konnte nicht gestartet werden."); setStartError(e?.message || (language === "en" ? "The game could not be started." : "Das Spiel konnte nicht gestartet werden."));
} finally { } finally {
setStarting(false); setStarting(false);
} }
@@ -68,7 +70,7 @@ export default function GamePickerCard({
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div> <div style={styles.sectionHeader}>{t("game")}</div>
<div style={styles.cardBody}> <div style={styles.cardBody}>
<select <select
@@ -83,8 +85,8 @@ export default function GamePickerCard({
))} ))}
</select> </select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe"> <button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
Hilfe {t("help")}
</button> </button>
</div> </div>
@@ -122,15 +124,15 @@ export default function GamePickerCard({
<div className="hp-lobby-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}> <div className="hp-lobby-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<div> <div>
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 15 }}> <div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 15 }}>
Lobby {t("lobby")}
</div> </div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}> <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>
{isHost ? "Du bist der Host dieses Spiels." : "Du bist der Lobby beigetreten."} {isHost ? (language === "en" ? "You are the host of this game." : "Du bist der Host dieses Spiels.") : (language === "en" ? "You joined the lobby." : "Du bist der Lobby beigetreten.")}
</div> </div>
</div> </div>
<div style={{ textAlign: "right", color: stylesTokens.textGold, fontWeight: 1000 }}> <div style={{ textAlign: "right", color: stylesTokens.textGold, fontWeight: 1000 }}>
<div style={{ fontSize: 20, lineHeight: 1 }}>{members.length}</div> <div style={{ fontSize: 20, lineHeight: 1 }}>{members.length}</div>
<div style={{ fontSize: 11, color: stylesTokens.textDim, fontWeight: 700 }}>Spieler</div> <div style={{ fontSize: 11, color: stylesTokens.textDim, fontWeight: 700 }}>{t("players")}</div>
</div> </div>
</div> </div>
@@ -138,7 +140,7 @@ export default function GamePickerCard({
{members.length > 0 ? members.map((m, index) => { {members.length > 0 ? members.map((m, index) => {
const isMe = String(me?.id) === String(m.id); const isMe = String(me?.id) === String(m.id);
const isMemberHost = String(hostUserId) === String(m.id); const isMemberHost = String(hostUserId) === String(m.id);
const name = ((m.display_name || "").trim() || (m.email || "").trim() || "Spieler"); const name = ((m.display_name || "").trim() || (m.email || "").trim() || t("players"));
return ( return (
<div key={m.id} className="hp-lobby-player" style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", borderRadius: 11, background: "rgba(255,255,255,0.055)", border: "1px solid rgba(233,216,166,0.10)" }}> <div key={m.id} className="hp-lobby-player" style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", borderRadius: 11, background: "rgba(255,255,255,0.055)", border: "1px solid rgba(233,216,166,0.10)" }}>
<span style={{ color: stylesTokens.textDim, fontSize: 12, minWidth: 16 }}>{index + 1}</span> <span style={{ color: stylesTokens.textDim, fontSize: 12, minWidth: 16 }}>{index + 1}</span>
@@ -150,7 +152,7 @@ export default function GamePickerCard({
); );
}) : ( }) : (
<div style={{ padding: "10px 4px", color: stylesTokens.textDim, fontSize: 13 }}> <div style={{ padding: "10px 4px", color: stylesTokens.textDim, fontSize: 13 }}>
Warte auf Spieler {language === "en" ? "Waiting for players …" : "Warte auf Spieler …"}
</div> </div>
)} )}
</div> </div>
@@ -162,15 +164,15 @@ export default function GamePickerCard({
style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }} style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }}
disabled={starting || members.length < 2} disabled={starting || members.length < 2}
> >
{starting ? "Spiel wird gestartet …" : "▶ Spiel starten"} {starting ? (language === "en" ? "Starting game …" : "Spiel wird gestartet …") : `${t("startGame")}`}
</button> </button>
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}> <div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
{members.length < 2 ? "Mindestens 2 Spieler werden benötigt." : "Beim Start werden die Spieler-Chips erstellt."} {members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
</div> </div>
</> </>
) : ( ) : (
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}> <div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
Warte, bis der Host das Spiel startet. {t("waitingForHost")}
</div> </div>
)} )}
@@ -179,8 +181,8 @@ export default function GamePickerCard({
) : ( ) : (
<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 }}> <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 {finished
? `🏆 Spiel beendet${winnerName ? ` · Sieger: ${winnerName}` : ""}` ? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
: `Spiel läuft · ${chipCount} Spieler-Chips erstellt`} : `${t("started")} · ${chipCount} ${t("chipsCreated")}`}
</div> </div>
)} )}
@@ -198,7 +200,7 @@ export default function GamePickerCard({
gap: 10, gap: 10,
}} }}
> >
<div>Spieler</div> <div>{t("players")}</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length} {members.length}
</div> </div>
@@ -2,8 +2,10 @@ import React, { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GameStartCelebration({ open, onClose }) { export default function GameStartCelebration({ open, onClose }) {
const { t, language } = useLanguage();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -29,14 +31,14 @@ export default function GameStartCelebration({ open, onClose }) {
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className="hp-start-overlay" onClick={onClose} role="dialog" aria-label="Spiel gestartet"> <div className="hp-start-overlay" onClick={onClose} role="dialog" aria-label={t("gameStarted")}>
<div className="hp-start-card" onClick={(event) => event.stopPropagation()}> <div className="hp-start-card" onClick={(event) => event.stopPropagation()}>
<div className="hp-start-rune"></div> <div className="hp-start-rune"></div>
<div className="hp-start-kicker">Die Ermittlungen beginnen</div> <div className="hp-start-kicker">{t("investigationsBegin")}</div>
<div className="hp-start-title">Spiel gestartet</div> <div className="hp-start-title">{t("gameStarted")}</div>
<div className="hp-start-subtitle">Möge der beste Detektiv gewinnen.</div> <div className="hp-start-subtitle">{t("bestDetective")}</div>
<button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}> <button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}>
Los geht&apos;s {language === "en" ? "Lets go" : "Los geht's"}
</button> </button>
</div> </div>
</div>, </div>,
+42 -40
View File
@@ -1,122 +1,121 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function HelpModal({ open, onClose }) { export default function HelpModal({ open, onClose }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const en = language === "en";
return ( return (
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<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 }}>Hilfe & Spielablauf</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("helpTitle")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={styles.helpBody}> <div style={styles.helpBody}>
<div style={styles.helpSectionTitle}>1) Lobby & Spielstart</div> <div style={styles.helpSectionTitle}>1) {en ? "Lobby & starting the game" : "Lobby & Spielstart"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Ein neues Spiel startet zunächst als Lobby. Teile den angezeigten <b>Spiel-Code</b> mit den {en ? <>A new game starts as a lobby. Share the displayed <b>game code</b> with the other players. They can join until the host starts the game.</> : <>Ein neues Spiel startet zunächst als Lobby. Teile den angezeigten <b>Spiel-Code</b> mit den anderen Spielern. Sie können dem Spiel beitreten, solange es noch nicht gestartet wurde.</>}
anderen Spielern. Sie können dem Spiel beitreten, solange es noch nicht gestartet wurde.
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>👑</span> <span style={styles.helpMiniTag}>👑</span>
<div> <div>
Der <b>Host</b> sieht alle beigetretenen Spieler und startet das Spiel. {en ? <>The <b>host</b> sees all joined players and starts the game.</> : <>Der <b>Host</b> sieht alle beigetretenen Spieler und startet das Spiel.</>}
</div> </div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div> <div>
Der Start ist ab <b>zwei Spielern</b> möglich. Danach können keine weiteren Spieler {en ? <>The game can start with <b>two players</b>. No additional players can join afterwards.</> : <>Der Start ist ab <b>zwei Spielern</b> möglich. Danach können keine weiteren Spieler beitreten.</>}
beitreten.
</div> </div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>SNE</span> <span style={styles.helpMiniTag}>SNE</span>
<div> <div>
Beim Start werden die Spieler-Chips automatisch erstellt. Beispiel: Sascha Nesterovic {en ? <>Player chips are created automatically when the game starts. Example: Sascha Nesterovic becomes <b>SNE</b>.</> : <>Beim Start werden die Spieler-Chips automatisch erstellt. Beispiel: Sascha Nesterovic wird zu <b>SNE</b>.</>}
wird zu <b>SNE</b>.
</div> </div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>2) Spiel auswählen</div> <div style={styles.helpSectionTitle}>2) {en ? "Selecting a game" : "Spiel auswählen"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Mit dem Dropdown im Bereich <b>Spiel</b> wechselst du zwischen deinen Spielen. Über {en ? <>Use the <b>Game</b> dropdown to switch between your games. With <b>New Game</b> you can create or join a game.</> : <>Mit dem Dropdown im Bereich <b>Spiel</b> wechselst du zwischen deinen Spielen. Über <b>New Game</b> kannst du ein neues Spiel erstellen oder einem bestehenden Spiel beitreten.</>}
<b> New Game</b> kannst du ein neues Spiel erstellen oder einem bestehenden Spiel beitreten.
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div> <div>
<b>Spiel-Auswahl</b> = vorhandene Spiele öffnen <b>{en ? "Game selection" : "Spiel-Auswahl"}</b> = {en ? "open existing games" : "vorhandene Spiele öffnen"}
</div> </div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div> <div>
<b>New Game</b> = neues Spiel erstellen oder per Code beitreten <b>New Game</b> = {en ? "create a game or join with a code" : "neues Spiel erstellen oder per Code beitreten"}
</div> </div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>3) Namen antippen Status</div> <div style={styles.helpSectionTitle}>3) {en ? "Tap a name status" : "Namen antippen Status"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Tippe während des laufenden Spiels auf den Namen eines Eintrags. Die Statusfolge ist: {en ? "During the game, tap an entry name. The status cycle is:" : "Tippe während des laufenden Spiels auf den Namen eines Eintrags. Die Statusfolge ist:"}
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span> <span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span>
<div><b>Grün</b> = bestätigt / vorhanden</div> <div><b>{en ? "Green" : "Grün"}</b> = {en ? "confirmed / present" : "bestätigt / vorhanden"}</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> <span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}></span>
<div><b>Rot</b> = ausgeschlossen / nicht vorhanden</div> <div><b>{en ? "Red" : "Rot"}</b> = {en ? "excluded / not present" : "ausgeschlossen / nicht vorhanden"}</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> <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><b>{en ? "Grey" : "Grau"}</b> = {en ? "uncertain / maybe" : "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> <span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}></span>
<div><b>Leer</b> = noch nicht bewertet</div> <div><b>{en ? "Empty" : "Leer"}</b> = {en ? "not evaluated yet" : "noch nicht bewertet"}</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>4) Notizen & Spieler-Chips</div> <div style={styles.helpSectionTitle}>4) {en ? "Notes & player chips" : "Notizen & Spieler-Chips"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Der Button rechts in jeder Zeile rotiert durch deine persönlichen Notizen: {en ? "The button on the right of each row cycles through your personal notes:" : "Der Button rechts in jeder Zeile rotiert durch deine persönlichen Notizen:"}
</div> </div>
<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 Karte</div> <div><b>i</b> = {en ? "I have this card" : "Ich habe diese Karte"}</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> = Karte aus dem mittleren Deck</div> <div><b>m</b> = {en ? "card from the middle deck" : "Karte 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 die Karte; danach wählst du den passenden Chip</div> <div><b>s</b> = {en ? "another player has the card; then choose the matching chip" : "Ein anderer Spieler hat die Karte; danach wählst du den passenden Chip"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>SNE</span> <span style={styles.helpMiniTag}>SNE</span>
<div><b>s.SNE</b> = Sascha Nesterovic besitzt die Karte</div> <div><b>s.SNE</b> = {en ? "Sascha Nesterovic has the card" : "Sascha Nesterovic besitzt die Karte"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
@@ -126,53 +125,56 @@ export default function HelpModal({ open, onClose }) {
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>5) Spielende</div> <div style={styles.helpSectionTitle}>5) {en ? "Game end" : "Spielende"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Nur der Host kann den Sieger festlegen. Sobald ein Sieger gespeichert wurde, gilt das Spiel {en ? "Only the host can select the winner. Once a winner is saved, the game is finished:" : "Nur der Host kann den Sieger festlegen. Sobald ein Sieger gespeichert wurde, gilt das Spiel als beendet:"}
als beendet:
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span> <span style={styles.helpMiniTag}>🔒</span>
<div>Alle Notizzettel werden <b>schreibgeschützt</b>.</div> <div>{en ? <>All note sheets become <b>read-only</b>.</> : <>Alle Notizzettel werden <b>schreibgeschützt</b>.</>}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🏆</span> <span style={styles.helpMiniTag}>🏆</span>
<div>Der Sieger wird für alle Spieler angezeigt.</div> <div>{en ? "The winner is shown to all players." : "Der Sieger wird für alle Spieler angezeigt."}</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>6) User-Menü</div> <div style={styles.helpSectionTitle}>6) {en ? "User menu" : "User-Menü"}</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Oben rechts findest du abhängig von deiner Rolle das <b>User</b>- oder <b>Admin</b>-Menü. {en ? <>At the top right, you will find the <b>User</b> or <b>Admin</b> menu depending on your role.</> : <>Oben rechts findest du abhängig von deiner Rolle das <b>User</b>- oder <b>Admin</b>-Menü.</>}
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>📊</span> <span style={styles.helpMiniTag}>📊</span>
<div><b>Statistik</b> = persönliche Spiele- und Siegstatistik</div> <div><b>{t("statistics")}</b> = {en ? "personal game and win statistics" : "persönliche Spiele- und Siegstatistik"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🎨</span> <span style={styles.helpMiniTag}>🎨</span>
<div><b>Design ändern</b> = Theme auswählen</div> <div><b>{t("changeDesign")}</b> = {en ? "choose a theme" : "Theme auswählen"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span> <span style={styles.helpMiniTag}>🔒</span>
<div><b>Passwort setzen</b> = eigenes Passwort ändern</div> <div><b>{t("setPassword")}</b> = {en ? "change your password" : "eigenes Passwort ändern"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div><b>Logout</b> = abmelden</div> <div><b>{t("logout")}</b> = {en ? "sign out" : "abmelden"}</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🛡</span> <span style={styles.helpMiniTag}>🛡</span>
<div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div> <div><b>{t("adminDashboard")}</b> = {en ? "create and edit users, change roles, set passwords and disable accounts" : "User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren"}</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div><b>{t("adminSettings")}</b> = {en ? "configure SMTP and send invitations without a preset password" : "SMTP konfigurieren und Einladungen ohne vorgegebenes Passwort versenden"}</div>
</div> </div>
</div> </div>
<div style={{ ...styles.helpText, marginTop: 16 }}> <div style={{ ...styles.helpText, marginTop: 16 }}>
Deine Notizen bleiben privat. Jeder Spieler sieht nur seinen eigenen Notizzettel. {en ? "Your notes remain private. Each player sees only their own note sheet." : "Deine Notizen bleiben privat. Jeder Spieler sieht nur seinen eigenen Notizzettel."}
</div> </div>
</div> </div>
</div> </div>
+42
View File
@@ -0,0 +1,42 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function InvitePage({ token }) {
const { language, t } = useLanguage();
const [info, setInfo] = useState(null);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [message, setMessage] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { api(`/auth/invite/${encodeURIComponent(token)}`).then(setInfo).catch((e) => setMessage("❌ " + (e?.message || "Diese Einladung ist nicht mehr gültig."))); }, [token]);
const submit = async (event) => {
event.preventDefault(); setMessage("");
if (password.length < 8) return setMessage(`${language === "en" ? "Password must be at least 8 characters." : "Das Passwort muss mindestens 8 Zeichen haben."}`);
if (password !== confirm) return setMessage(`${language === "en" ? "Passwords do not match." : "Die Passwörter stimmen nicht überein."}`);
setSaving(true);
try { await api(`/auth/invite/${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ password }) }); setMessage("✅ Passwort gesetzt. Du wirst weitergeleitet …"); setTimeout(() => { window.location.href = "/"; }, 700); }
catch (e) { setMessage("❌ " + (e?.message || "Einladung konnte nicht aktiviert werden.")); setSaving(false); }
};
return <div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true"><div style={styles.bgMap} /></div>
<main className="hp-invite-card" style={{ ...styles.loginCard, position: "relative", zIndex: 1 }}>
<div className="hp-invite-seal"></div>
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 24 }}>Notizbogen</div>
<div style={{ color: stylesTokens.textDim, marginTop: 5 }}>{t("invite")}</div>
{info ? <>
<div style={{ marginTop: 22, padding: "14px 16px", borderRadius: 14, background: "rgba(233,216,166,.08)", border: "1px solid rgba(233,216,166,.18)", textAlign: "left" }}><div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 18 }}>{info.display_name}</div><div style={{ color: stylesTokens.textDim, fontSize: 13, marginTop: 4 }}>{info.email} · {info.role}</div></div>
<form onSubmit={submit} style={{ marginTop: 18, display: "grid", gap: 10, textAlign: "left" }}>
<label style={styles.adminFieldLabel}>{t("password")}<input autoFocus type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("passwordMin")} style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("confirmPassword")}<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={t("passwordAgain")} style={styles.input} /></label>
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
<button type="submit" style={{ ...styles.primaryBtn, width: "100%", marginTop: 5 }} disabled={saving}>{saving ? (language === "en" ? "Activating …" : "Wird aktiviert …") : t("acceptInvite")}</button>
</form>
</> : <div style={{ marginTop: 24, color: message ? "#ffb3b3" : stylesTokens.textDim }}>{message || "Einladung wird geprüft …"}</div>}
</main>
</div>;
}
+6 -4
View File
@@ -2,8 +2,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function JoinGameModal({ open, onClose, onJoin }) { export default function JoinGameModal({ open, onClose, onJoin }) {
const { language, t } = useLanguage();
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -19,13 +21,13 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
const doJoin = async () => { const doJoin = async () => {
const c = (code || "").trim(); const c = (code || "").trim();
if (!c) return setMsg("Bitte Code eingeben."); if (!c) return setMsg(`${language === "en" ? "Please enter a code." : "Bitte Code eingeben."}`);
setBusy(true); setBusy(true);
setMsg(""); setMsg("");
try { try {
await onJoin(c); await onJoin(c);
} catch (e) { } catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown")); setMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setBusy(false); setBusy(false);
} }
}; };
@@ -34,8 +36,8 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<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 }}>Spiel beitreten</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("joinGame")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
+16 -14
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { useLanguage } from "../i18n";
export default function LoginPage({ export default function LoginPage({
loginEmail, loginEmail,
@@ -22,6 +23,7 @@ export default function LoginPage({
setupSaving, setupSaving,
doSetup, doSetup,
}) { }) {
const { language, t } = useLanguage();
const isSetup = setupRequired === true; const isSetup = setupRequired === true;
return ( return (
@@ -37,10 +39,10 @@ export default function LoginPage({
<div style={styles.loginSubtitle}> <div style={styles.loginSubtitle}>
{setupRequired === null {setupRequired === null
? "Initialisiere Anwendung …" ? (language === "en" ? "Initializing application …" : "Initialisiere Anwendung …")
: isSetup : isSetup
? "Richte den ersten Administrator ein" ? (language === "en" ? "Set up the first administrator" : "Richte den ersten Administrator ein")
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"} : (language === "en" ? "Log in to open your Cluedo sheet" : "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen")}
</div> </div>
{isSetup ? ( {isSetup ? (
@@ -49,7 +51,7 @@ export default function LoginPage({
<input <input
value={setupDisplayName} value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)} onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name" placeholder={t("displayName")}
style={styles.loginInput} style={styles.loginInput}
autoComplete="name" autoComplete="name"
/> />
@@ -59,7 +61,7 @@ export default function LoginPage({
<input <input
value={setupEmail} value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)} onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email" placeholder={language === "en" ? "Admin email" : "Admin-E-Mail"}
style={styles.loginInput} style={styles.loginInput}
inputMode="email" inputMode="email"
autoComplete="username" autoComplete="username"
@@ -70,7 +72,7 @@ export default function LoginPage({
<input <input
value={setupPassword} value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)} onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)" placeholder={t("passwordMin")}
type="password" type="password"
style={styles.loginInput} style={styles.loginInput}
autoComplete="new-password" autoComplete="new-password"
@@ -81,7 +83,7 @@ export default function LoginPage({
<input <input
value={setupPasswordConfirm} value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)} onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password" placeholder={t("confirmPassword")}
type="password" type="password"
style={styles.loginInput} style={styles.loginInput}
autoComplete="new-password" autoComplete="new-password"
@@ -91,7 +93,7 @@ export default function LoginPage({
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>} {setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}> <button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? "Setting up …" : "✦ Create administrator"} {setupSaving ? (language === "en" ? "Setting up …" : "Einrichtung …") : (language === "en" ? "✦ Create administrator" : "✦ Administrator erstellen")}
</button> </button>
</div> </div>
) : ( ) : (
@@ -100,7 +102,7 @@ export default function LoginPage({
<input <input
value={loginEmail} value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)} onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email" placeholder={t("email")}
style={styles.loginInput} style={styles.loginInput}
inputMode="email" inputMode="email"
autoComplete="username" autoComplete="username"
@@ -112,7 +114,7 @@ export default function LoginPage({
<input <input
value={loginPassword} value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)} onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort" placeholder={t("password")}
type={showPw ? "text" : "password"} type={showPw ? "text" : "password"}
style={styles.inputInRow} style={styles.inputInRow}
autoComplete="current-password" autoComplete="current-password"
@@ -121,8 +123,8 @@ export default function LoginPage({
type="button" type="button"
onClick={() => setShowPw((v) => !v)} onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn} style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"} aria-label={showPw ? (language === "en" ? "Hide password" : "Passwort verstecken") : (language === "en" ? "Show password" : "Passwort anzeigen")}
title={showPw ? "Verstecken" : "Anzeigen"} title={showPw ? (language === "en" ? "Hide" : "Verstecken") : (language === "en" ? "Show" : "Anzeigen")}
> >
{showPw ? "🙈" : "👁"} {showPw ? "🙈" : "👁"}
</button> </button>
@@ -130,13 +132,13 @@ export default function LoginPage({
</div> </div>
<button onClick={doLogin} style={styles.loginBtn}> <button onClick={doLogin} style={styles.loginBtn}>
Anmelden {t("login")}
</button> </button>
</div> </div>
)} )}
<div style={styles.loginHint}> <div style={styles.loginHint}>
Your notes remain private every player only sees their own sheet. {language === "en" ? "Your notes remain private every player only sees their own sheet." : "Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Notizzettel."}
</div> </div>
</div> </div>
</div> </div>
+24 -22
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function NewGameModal({ export default function NewGameModal({
open, open,
@@ -13,6 +14,7 @@ export default function NewGameModal({
gameFinished = false, gameFinished = false,
hasGame = false, hasGame = false,
}) { }) {
const { language, t } = useLanguage();
// modes: running | choice | create | join // modes: running | choice | create | join
const [mode, setMode] = useState("choice"); const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState(""); const [joinCode, setJoinCode] = useState("");
@@ -53,7 +55,7 @@ export default function NewGameModal({
setCreated({ code: res.code }); setCreated({ code: res.code });
setMode("create"); setMode("create");
} catch (e) { } catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown")); setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
@@ -63,16 +65,16 @@ export default function NewGameModal({
await onJoin(joinCode.trim().toUpperCase()); await onJoin(joinCode.trim().toUpperCase());
onClose(); onClose();
} catch (e) { } catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown")); setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
const copyText = async (text, okMsg = "✅ Code kopiert") => { const copyText = async (text, okMsg = language === "en" ? "✅ Code copied" : "✅ Code kopiert") => {
try { try {
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
showToast(okMsg); showToast(okMsg);
} catch { } catch {
showToast("❌ Copy nicht möglich"); showToast(language === "en" ? "❌ Copy failed" : "❌ Copy nicht möglich");
} }
}; };
@@ -85,9 +87,9 @@ export default function NewGameModal({
<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 }}>
Spiel {t("game")}
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -116,7 +118,7 @@ export default function NewGameModal({
{mode === "running" && ( {mode === "running" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>: {language === "en" ? <>The game is in progress. Here is the <b>join code</b>:</> : <>Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:</>}
</div> </div>
<div <div
@@ -131,7 +133,7 @@ export default function NewGameModal({
}} }}
> >
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code {t("gameCode")}
</div> </div>
<div <div
@@ -150,19 +152,19 @@ export default function NewGameModal({
onClick={() => copyText(codeToShow)} onClick={() => copyText(codeToShow)}
style={styles.primaryBtn} style={styles.primaryBtn}
disabled={!codeToShow} disabled={!codeToShow}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"} title={!codeToShow ? (language === "en" ? "No code available" : "Kein Code verfügbar") : (language === "en" ? "Copy code" : "Code kopieren")}
> >
Code kopieren {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten. {language === "en" ? "Once a winner is selected, you can create or join a new game here." : "Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten."}
</div> </div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}> <button onClick={onClose} style={styles.primaryBtn}>
Fertig {language === "en" ? "Done" : "Fertig"}
</button> </button>
</div> </div>
</> </>
@@ -173,15 +175,15 @@ export default function NewGameModal({
{mode === "choice" && ( {mode === "choice" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>? {language === "en" ? <>Would you like to <b>create</b> a game or <b>join</b> one?</> : <>Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?</>}
</div> </div>
<button onClick={doCreate} style={styles.primaryBtn}> <button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen {t("createGame")}
</button> </button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}> <button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten {t("joinGame")}
</button> </button>
</> </>
)} )}
@@ -189,7 +191,7 @@ export default function NewGameModal({
{mode === "join" && ( {mode === "join" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Gib den <b>Code</b> ein: {language === "en" ? <>Enter the <b>code</b>:</> : <>Gib den <b>Code</b> ein:</>}
</div> </div>
<input <input
@@ -202,10 +204,10 @@ export default function NewGameModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}> <button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück {language === "en" ? "Back" : "Zurück"}
</button> </button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}> <button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten {t("join")}
</button> </button>
</div> </div>
</> </>
@@ -214,7 +216,7 @@ export default function NewGameModal({
{mode === "create" && created && ( {mode === "create" && created && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei Alte Spiele sichtbar: {language === "en" ? "Your game was created. This code will remain visible in your old games:" : "Dein Spiel wurde erstellt. Dieser Code bleibt auch bei „Alte Spiele“ sichtbar:"}
</div> </div>
<div <div
@@ -229,7 +231,7 @@ export default function NewGameModal({
}} }}
> >
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code {t("gameCode")}
</div> </div>
<div <div
@@ -245,13 +247,13 @@ export default function NewGameModal({
</div> </div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}> <button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
</div> </div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}> <button onClick={onClose} style={styles.primaryBtn}>
Fertig {language === "en" ? "Done" : "Fertig"}
</button> </button>
</div> </div>
</> </>
+9 -7
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function PasswordModal({ export default function PasswordModal({
pwOpen, pwOpen,
@@ -13,14 +14,15 @@ export default function PasswordModal({
pwSaving, pwSaving,
savePassword, savePassword,
}) { }) {
const { language, t } = useLanguage();
if (!pwOpen) return null; if (!pwOpen) return null;
return ( return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}> <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 }}>Passwort setzen</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("setPasswordTitle")}</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={closePwModal} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -29,7 +31,7 @@ export default function PasswordModal({
<input <input
value={pw1} value={pw1}
onChange={(e) => setPw1(e.target.value)} onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort" placeholder={language === "en" ? "New password" : "Neues Passwort"}
type="password" type="password"
style={styles.input} style={styles.input}
autoFocus autoFocus
@@ -37,7 +39,7 @@ export default function PasswordModal({
<input <input
value={pw2} value={pw2}
onChange={(e) => setPw2(e.target.value)} onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen" placeholder={t("passwordAgain")}
type="password" type="password"
style={styles.input} style={styles.input}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -49,15 +51,15 @@ export default function PasswordModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}> <button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen {t("cancel")}
</button> </button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}> <button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"} {pwSaving ? (language === "en" ? "Saving…" : "Speichern") : t("save")}
</button> </button>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Mindestens 8 Zeichen empfohlen. {language === "en" ? "Note: At least 8 characters are recommended." : "Hinweis: Mindestens 8 Zeichen empfohlen."}
</div> </div>
</div> </div>
</div> </div>
+4 -2
View File
@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function SheetSection({ export default function SheetSection({
title, title,
@@ -12,6 +13,7 @@ export default function SheetSection({
displayTag, displayTag,
readOnly = false, readOnly = false,
}) { }) {
const { t } = useLanguage();
const getRowBg = (status) => { const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg; if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg; if (status === 2) return stylesTokens.rowOkBg;
@@ -79,7 +81,7 @@ export default function SheetSection({
opacity: effectiveStatus === 1 ? 0.8 : 1, opacity: effectiveStatus === 1 ? 0.8 : 1,
cursor: readOnly ? "default" : "pointer", cursor: readOnly ? "default" : "pointer",
}} }}
title={readOnly ? "Spiel beendet Notizzettel ist schreibgeschützt" : "Klick: Grün → Rot → Grau → Leer"} title={readOnly ? t("readOnly") : t("cycleStatus")}
> >
{e.label} {e.label}
</div> </div>
@@ -100,7 +102,7 @@ export default function SheetSection({
onClick={() => !readOnly && onToggleTag(e)} onClick={() => !readOnly && onToggleTag(e)}
style={styles.tagBtn} style={styles.tagBtn}
disabled={readOnly} disabled={readOnly}
title={readOnly ? "Spiel beendet Notizzettel ist schreibgeschützt" : "— → i → m → s.(Chip) → —"} title={readOnly ? t("readOnly") : t("cycleNote")}
> >
{displayTag(e)} {displayTag(e)}
</button> </button>
+9 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
function Tile({ label, value, sub }) { function Tile({ label, value, sub }) {
return ( return (
@@ -48,6 +49,7 @@ function Tile({ label, value, sub }) {
} }
export default function StatsModal({ open, onClose, me, stats, loading, error }) { export default function StatsModal({ open, onClose, me, stats, loading, error }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
@@ -57,13 +59,13 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<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> <div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("statistics")}</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName} {displayName}
</div> </div>
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -71,7 +73,7 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
{loading ? ( {loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}> <div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik {language === "en" ? "Loading statistics…" : "Lade Statistik…"}
</div> </div>
) : error ? ( ) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div> <div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
@@ -83,10 +85,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
gap: 10, gap: 10,
}} }}
> >
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} /> <Tile label={language === "en" ? "Games played" : "Gespielte Spiele"} value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} /> <Tile label={language === "en" ? "Wins" : "Siege"} value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} /> <Tile label={language === "en" ? "Losses" : "Verluste"} value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" /> <Tile label={language === "en" ? "Win rate" : "Siegerate"} value={`${stats?.winrate ?? 0}%`} sub={language === "en" ? "finished games only" : "nur beendete Spiele"} />
</div> </div>
)} )}
</div> </div>
+31 -8
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function TopBar({ export default function TopBar({
me, me,
@@ -10,12 +11,14 @@ export default function TopBar({
openDesignModal, openDesignModal,
openStatsModal, openStatsModal,
openAdminPanel, openAdminPanel,
openAdminSettings,
doLogout, doLogout,
onOpenNewGame, onOpenNewGame,
}) { }) {
const { toggleLanguage, t } = useLanguage();
const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
const isAdmin = me?.role === "admin"; const isAdmin = me?.role === "admin";
const roleLabel = isAdmin ? "Admin" : "User"; const roleLabel = isAdmin ? t("admin") : t("user");
return ( return (
<div className="hp-topbar" style={styles.topBar}> <div className="hp-topbar" style={styles.topBar}>
@@ -32,7 +35,7 @@ export default function TopBar({
className="hp-topbar-user" className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)} onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn} style={styles.userBtn}
title="User Menü" title={t("user") + " menu"}
> >
<span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span> <span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span>
<span>{roleLabel}</span> <span>{roleLabel}</span>
@@ -60,7 +63,7 @@ export default function TopBar({
}} }}
style={styles.userDropdownItem} style={styles.userDropdownItem}
> >
Statistik {t("statistics")}
</button> </button>
{isAdmin && ( {isAdmin && (
@@ -73,7 +76,16 @@ export default function TopBar({
}} }}
style={styles.userDropdownItem} style={styles.userDropdownItem}
> >
Admin Dashboard {t("adminDashboard")}
</button>
<button
onClick={() => {
setUserMenuOpen(false);
openAdminSettings?.();
}}
style={styles.userDropdownItem}
>
{t("adminSettings")}
</button> </button>
</> </>
)} )}
@@ -81,15 +93,26 @@ export default function TopBar({
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen {t("setPassword")}
</button> </button>
<button onClick={openDesignModal} style={styles.userDropdownItem}> <button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern {t("changeDesign")}
</button> </button>
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button
onClick={() => {
toggleLanguage();
setUserMenuOpen(false);
}}
style={styles.userDropdownItem}
title={`${t("changeLanguage")}: ${t("switchTo")}`}
>
🌐 {t("changeLanguage")}
</button>
<button <button
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
@@ -97,14 +120,14 @@ export default function TopBar({
}} }}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }} style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
> >
Logout {t("logout")}
</button> </button>
</div> </div>
)} )}
</div> </div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}> <button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game {t("newGame")}
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
/** /**
* Props: * Props:
@@ -7,6 +8,7 @@ import { stylesTokens } from "../styles/theme";
* - winnerEmail: string | null (legacy fallback) * - winnerEmail: string | null (legacy fallback)
*/ */
export default function WinnerBadge({ winner, winnerEmail }) { export default function WinnerBadge({ winner, winnerEmail }) {
const { language } = useLanguage();
const name = const name =
(winner?.display_name || "").trim() || (winner?.display_name || "").trim() ||
(winner?.email || "").trim() || (winner?.email || "").trim() ||
@@ -34,13 +36,13 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ fontSize: 18 }}>🏆</div> <div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}> <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger: {language === "en" ? "Winner:" : "Sieger:"}
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span> <span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div> </div>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt {language === "en" ? "selected" : "festgelegt"}
</div> </div>
</div> </div>
); );
+6 -4
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCard({ export default function WinnerCard({
isHost, isHost,
@@ -9,12 +10,13 @@ export default function WinnerCard({
setWinnerUserId, setWinnerUserId,
onSave, onSave,
}) { }) {
const { language, t } = useLanguage();
if (!isHost) return null; if (!isHost) return null;
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div> <div style={styles.sectionHeader}>{t("winner")}</div>
<div style={styles.cardBody}> <div style={styles.cardBody}>
<select <select
@@ -22,7 +24,7 @@ export default function WinnerCard({
onChange={(e) => setWinnerUserId(e.target.value || "")} onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }} style={{ ...styles.input, flex: 1 }}
> >
<option value=""> kein Sieger </option> <option value=""> {language === "en" ? "no winner" : "kein Sieger"} </option>
{members.map((m) => { {members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim()); const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return ( return (
@@ -34,12 +36,12 @@ export default function WinnerCard({
</select> </select>
<button onClick={onSave} style={styles.primaryBtn}> <button onClick={onSave} style={styles.primaryBtn}>
Speichern {t("save")}
</button> </button>
</div> </div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}> <div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Nur der Host (Spiel-Ersteller) kann den Sieger setzen. {language === "en" ? "Only the host (game creator) can select the winner." : "Nur der Host (Spiel-Ersteller) kann den Sieger setzen."}
</div> </div>
</div> </div>
</div> </div>
@@ -2,8 +2,10 @@ import React, { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCelebration({ open, winnerName, onClose }) { export default function WinnerCelebration({ open, winnerName, onClose }) {
const { language } = useLanguage();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -146,15 +148,15 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
lineHeight: 1.25, lineHeight: 1.25,
}} }}
> >
Spieler{" "} {language === "en" ? "Player " : "Spieler "}
<span style={{ color: stylesTokens.textGold }}> <span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"} {winnerName || "Unbekannt"}
</span>{" "} </span>{" "}
hat die richtige Lösung! {language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
</div> </div>
<div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}> <div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}>
Fall gelöst. Respekt. {language === "en" ? "Case solved. Respect. ✨" : "Fall gelöst. Respekt. ✨"}
</div> </div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}> <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
+224
View File
@@ -0,0 +1,224 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
const STORAGE_KEY = "hpLanguage";
const LanguageContext = createContext(null);
export const translations = {
de: {
language: "Deutsch",
changeLanguage: "Sprache ändern",
switchTo: "English",
statistics: "Statistik",
adminDashboard: "Admin Dashboard",
adminSettings: "Admin Settings",
setPassword: "Passwort setzen",
changeDesign: "Design ändern",
logout: "Logout",
newGame: "New Game",
user: "User",
admin: "Admin",
help: "Hilfe",
game: "Spiel",
players: "Spieler",
suspects: "Verdächtige Personen",
items: "Gegenstände",
locations: "Orte",
winner: "Sieger",
started: "Spiel läuft",
finished: "Spiel beendet",
lobby: "Lobby",
startGame: "Spiel starten",
waitingForHost: "Warte, bis der Host das Spiel startet.",
chipsCreated: "Spieler-Chips erstellt",
atLeastTwoPlayers: "Mindestens 2 Spieler werden benötigt.",
chipsOnStart: "Beim Start werden die Spieler-Chips erstellt.",
noPlayersYet: "Noch keine Spieler beigetreten.",
close: "Schließen",
cancel: "Abbrechen",
save: "Speichern",
create: "Erstellen",
edit: "Bearbeiten",
delete: "Löschen",
disable: "Deaktivieren",
active: "aktiv",
disabled: "deaktiviert",
loading: "Laden …",
email: "E-Mail",
password: "Passwort",
confirmPassword: "Passwort bestätigen",
role: "Rolle",
displayName: "Anzeigename",
name: "Name",
createUser: "User anlegen",
editUser: "User bearbeiten",
newUser: "Neuen User anlegen",
existingUsers: "Vorhandene User",
accountActive: "Konto ist aktiv",
optional: "optional",
inviteHint: "Leer lassen = Einladung senden",
inviteNotice: "Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.",
adminDashboardSubtitle: "Benutzer, Rollen und Zugangsdaten verwalten",
adminSettingsSubtitle: "SMTP und Einladungen konfigurieren",
smtpServer: "SMTP-Server",
port: "Port",
smtpUsername: "SMTP-Benutzername",
senderEmail: "Absender-E-Mail",
senderName: "Absendername",
appUrl: "App-URL für Einladungslinks",
useTls: "STARTTLS verwenden (empfohlen)",
testEmail: "Test-E-Mail",
sendTest: "Test senden",
saveSettings: "Einstellungen speichern",
setupAdmin: "Admin einrichten",
login: "Anmelden",
logout: "Logout",
enterEmail: "E-Mail eingeben",
passwordMin: "Mindestens 8 Zeichen",
passwordAgain: "Passwort wiederholen",
acceptInvite: "Einladung annehmen",
invite: "Deine Einladung",
setPasswordTitle: "Passwort setzen",
design: "Design ändern",
joinGame: "Spiel beitreten",
newGameTitle: "Neues Spiel",
gameCode: "Spiel-Code",
copy: "Kopieren",
copied: "Kopiert",
createGame: "Spiel erstellen",
join: "Beitreten",
chooseAction: "Was möchtest du tun?",
helpTitle: "Hilfe & Spielablauf",
notes: "Notizen",
playerChips: "Spieler-Chips",
whoHasCard: "Wer hat die Karte?",
selectChip: "Chip auswählen",
readOnly: "Spiel beendet Notizzettel ist schreibgeschützt",
cycleStatus: "Klick: Grün → Rot → Grau → Leer",
cycleNote: "— → i → m → s.(Chip) → —",
gameStarted: "Spiel gestartet",
investigationsBegin: "Die Ermittlungen beginnen",
bestDetective: "Möge der beste Detektiv gewinnen.",
},
en: {
language: "English",
changeLanguage: "Change language",
switchTo: "Deutsch",
statistics: "Statistics",
adminDashboard: "Admin Dashboard",
adminSettings: "Admin Settings",
setPassword: "Set password",
changeDesign: "Change design",
logout: "Logout",
newGame: "New Game",
user: "User",
admin: "Admin",
help: "Help",
game: "Game",
players: "Players",
suspects: "Suspects",
items: "Items",
locations: "Locations",
winner: "Winner",
started: "Game in progress",
finished: "Game finished",
lobby: "Lobby",
startGame: "Start game",
waitingForHost: "Wait for the host to start the game.",
chipsCreated: "player chips created",
atLeastTwoPlayers: "At least 2 players are required.",
chipsOnStart: "Player chips will be created when the game starts.",
noPlayersYet: "No players have joined yet.",
close: "Close",
cancel: "Cancel",
save: "Save",
create: "Create",
edit: "Edit",
delete: "Delete",
disable: "Disable",
active: "active",
disabled: "disabled",
loading: "Loading …",
email: "Email",
password: "Password",
confirmPassword: "Confirm password",
role: "Role",
displayName: "Display name",
name: "Name",
createUser: "Create user",
editUser: "Edit user",
newUser: "Create new user",
existingUsers: "Existing users",
accountActive: "Account is active",
optional: "optional",
inviteHint: "Leave blank = send invitation",
inviteNotice: "Without a password, a one-time invitation is sent via SMTP.",
adminDashboardSubtitle: "Manage users, roles and credentials",
adminSettingsSubtitle: "Configure SMTP and invitations",
smtpServer: "SMTP server",
port: "Port",
smtpUsername: "SMTP username",
senderEmail: "Sender email",
senderName: "Sender name",
appUrl: "App URL for invitation links",
useTls: "Use STARTTLS (recommended)",
testEmail: "Test email",
sendTest: "Send test",
saveSettings: "Save settings",
setupAdmin: "Set up admin",
login: "Log in",
enterEmail: "Enter email",
passwordMin: "At least 8 characters",
passwordAgain: "Repeat password",
acceptInvite: "Accept invitation",
invite: "Your invitation",
setPasswordTitle: "Set password",
design: "Change design",
joinGame: "Join game",
newGameTitle: "New game",
gameCode: "Game code",
copy: "Copy",
copied: "Copied",
createGame: "Create game",
join: "Join",
chooseAction: "What would you like to do?",
helpTitle: "Help & game guide",
notes: "Notes",
playerChips: "Player chips",
whoHasCard: "Who has the card?",
selectChip: "Select chip",
readOnly: "Game finished note sheet is read-only",
cycleStatus: "Click: Green → Red → Grey → Empty",
cycleNote: "— → i → m → s.(chip) → —",
gameStarted: "Game started",
investigationsBegin: "The investigation begins",
bestDetective: "May the best detective win.",
},
};
export function LanguageProvider({ children }) {
const [language, setLanguage] = useState(() => {
try { return localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "de"; } catch { return "de"; }
});
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, language); } catch {}
document.documentElement.lang = language;
}, [language]);
const value = useMemo(() => ({
language,
setLanguage,
toggleLanguage: () => setLanguage((current) => current === "de" ? "en" : "de"),
t: (key, values = {}) => {
let value = translations[language][key] || key;
Object.entries(values).forEach(([name, replacement]) => { value = value.replaceAll(`{${name}}`, replacement); });
return value;
},
}), [language]);
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}
export function useLanguage() {
return useContext(LanguageContext) || { language: "de", toggleLanguage: () => {}, t: (key) => key };
}
+4 -1
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { LanguageProvider } from "./i18n.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
@@ -21,7 +22,9 @@ async function bootstrap() {
} catch {} } catch {}
// App rendern // App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />); ReactDOM.createRoot(document.getElementById("root")).render(
<LanguageProvider><App /></LanguageProvider>
);
// Splash mind. 3 Sekunden anzeigen (3000ms) // Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000; const MIN_SPLASH_MS = 3000;
@@ -113,10 +113,17 @@ export function useHpGlobalStyles() {
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; } .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); } .hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
.hp-admin-editor-card { overflow: hidden; } .hp-admin-editor-card { overflow: hidden; }
.hp-settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 10px; }
.hp-settings-test { display: flex; align-items: end; gap: 10px; }
.hp-invite-card { animation: popIn 480ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 24px 80px rgba(0,0,0,.6), 0 0 40px rgba(233,216,166,.08); }
.hp-invite-seal { width: 52px; height: 52px; margin: 0 auto 14px; display: grid; place-items: center; border: 1px solid rgba(233,216,166,.42); border-radius: 50%; color: var(--hp-textGold); font-size: 24px; box-shadow: 0 0 24px rgba(233,216,166,.12); }
.hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; } .hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; }
.hp-admin-status { color: #baf3c9 !important; font-size: 13px; } .hp-admin-status { color: #baf3c9 !important; font-size: 13px; }
.hp-admin-status--disabled { color: #ffb3b3 !important; } .hp-admin-status--disabled { color: #ffb3b3 !important; }
@media (max-width: 600px) { @media (max-width: 600px) {
.hp-settings-grid { grid-template-columns: 1fr; }
.hp-settings-test { align-items: stretch; flex-direction: column; }
.hp-settings-test button { width: 100%; }
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; } .hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
.hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; } .hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
.hp-topbar > div:first-child { min-width: 0; flex: 1; } .hp-topbar > div:first-child { min-width: 0; flex: 1; }
@@ -136,6 +143,7 @@ export function useHpGlobalStyles() {
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; } .hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; } .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-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; }
.hp-admin-editor-card button { min-height: 40px; } .hp-admin-editor-card button { min-height: 40px; }
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; } .hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
.hp-row button { min-height: 40px; } .hp-row button { min-height: 40px; }