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,
This commit is contained in:
2026-08-02 10:33:40 +02:00
parent d96b75ca82
commit 33a972e502
21 changed files with 472 additions and 200 deletions
+12 -10
View File
@@ -27,9 +27,11 @@ import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
import AdminSettingsModal from "./components/AdminSettingsModal";
import InvitePage from "./components/InvitePage";
import { useLanguage } from "./i18n";
export default function App() {
useHpGlobalStyles();
const { language, t } = useLanguage();
// Auth/Login UI state
const [me, setMe] = useState(null);
@@ -397,8 +399,8 @@ export default function App() {
const savePassword = async () => {
setPwMsg("");
if (!pw1 || pw1.length < 8) return setPwMsg("Passwort muss mindestens 8 Zeichen haben.");
if (pw1 !== pw2) return setPwMsg("Passwörter stimmen nicht überein.");
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(`${language === "en" ? "Passwords do not match." : "Passwörter stimmen nicht überein."}`);
setPwSaving(true);
try {
@@ -406,10 +408,10 @@ export default function App() {
method: "PATCH",
body: JSON.stringify({ password: pw1 }),
});
setPwMsg("Passwort gespeichert.");
setPwMsg(`${language === "en" ? "Password saved." : "Passwort gespeichert."}`);
setTimeout(() => closePwModal(), 650);
} catch (e) {
setPwMsg("❌ Fehler: " + (e?.message || "unknown"));
setPwMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally {
setPwSaving(false);
}
@@ -456,7 +458,7 @@ export default function App() {
setStats(s);
} catch (e) {
setStats(null);
setStatsError("❌ Fehler: " + (e?.message || "unknown"));
setStatsError("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally {
setStatsLoading(false);
}
@@ -492,7 +494,7 @@ export default function App() {
const g = await api("/games", {
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");
@@ -557,7 +559,7 @@ export default function App() {
if (next === "s") {
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;
}
setChipEntry(entry);
@@ -664,9 +666,9 @@ export default function App() {
const sections = sheet
? [
{ key: "suspect", title: "VERDÄCHTIGE PERSON", entries: sheet.suspect || [] },
{ key: "item", title: "GEGENSTAND", entries: sheet.item || [] },
{ key: "location", title: "ORT", entries: sheet.location || [] },
{ key: "suspect", title: t("suspects").toUpperCase(), entries: sheet.suspect || [] },
{ key: "item", title: t("items").toUpperCase(), entries: sheet.item || [] },
{ key: "location", title: t("locations").toUpperCase(), entries: sheet.location || [] },
]
: [];
+24 -22
View File
@@ -3,10 +3,12 @@ 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 emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false };
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const { language, t } = useLanguage();
const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
@@ -65,9 +67,9 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
const saveUser = async () => {
setMsg("");
if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich.");
if (!editingUser && form.password && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
if (editingUser && form.password && form.password.length < 8) return setMsg("Neues Passwort muss mindestens 8 Zeichen haben.");
if (!form.email.trim()) return setMsg(`${t("email")} ${language === "en" ? "is required." : "ist erforderlich."}`);
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(`${language === "en" ? "New password must be at least 8 characters." : "Neues Passwort muss mindestens 8 Zeichen haben."}`);
setSaving(true);
try {
@@ -84,10 +86,10 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
body: JSON.stringify(payload),
});
await loadUsers();
setNotice(result.invite_sent ? "✅ Einladung wurde per E-Mail versendet." : "✅ User gespeichert.");
setNotice(result.invite_sent ? (language === "en" ? "✅ Invitation sent by email." : "✅ Einladung wurde per E-Mail versendet.") : (language === "en" ? "✅ User saved." : "✅ User gespeichert."));
closeEditor();
} catch (e) {
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen."));
setMsg("❌ " + (e?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen.")));
} finally {
setSaving(false);
}
@@ -100,7 +102,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert("Fehler: " + (e?.message || "unknown"));
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
}
};
@@ -112,15 +114,15 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div style={{ padding: "18px 18px 16px" }}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Dashboard</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>Benutzer, Rollen und Zugangsdaten verwalten</div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminDashboard")}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{t("adminDashboardSubtitle")}</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div>
<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>
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button>
<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}>+ {t("createUser")}</button>
</div>
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
@@ -132,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-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
<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={() => 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={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button>
</div>
</div>
))}
@@ -146,21 +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 style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? "User bearbeiten" : "Neuen User anlegen"}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}</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 ? (language === "en" ? "Update profile and credentials" : "Profil und Zugangsdaten aktualisieren") : (language === "en" ? "Create a new user account" : "Ein neues Benutzerkonto erstellen")}</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 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}>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}>{editingUser ? "Neues Passwort (optional)" : "Passwort (optional)"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Leer lassen = Einladung senden"} 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>
{editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> Konto ist aktiv</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}>{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 ? `${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}>{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)} /> {t("accountActive")}</label>}
{msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>}
{!editingUser && <div style={{ color: stylesTokens.textDim, fontSize: 12 }}>Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.</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>,
+19 -17
View File
@@ -3,6 +3,7 @@ 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: "",
@@ -11,6 +12,7 @@ const initial = {
};
export default function AdminSettingsModal({ open, onClose }) {
const { language, t } = useLanguage();
const [form, setForm] = useState(initial);
const [configured, setConfigured] = useState(false);
const [recipient, setRecipient] = useState("");
@@ -24,7 +26,7 @@ export default function AdminSettingsModal({ open, onClose }) {
api("/admin/settings/smtp").then((data) => {
setForm({ ...initial, ...data, smtp_password: "" });
setConfigured(!!data.smtp_password_configured);
}).catch((error) => setMessage("❌ " + (error?.message || "Laden fehlgeschlagen.")));
}).catch((error) => setMessage("❌ " + (error?.message || (language === "en" ? "Loading failed." : "Laden fehlgeschlagen."))));
}, [open]);
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
@@ -34,16 +36,16 @@ export default function AdminSettingsModal({ open, onClose }) {
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 || "Speichern fehlgeschlagen.")); }
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen."))); }
finally { setSaving(false); }
};
const test = async () => {
if (!recipient.trim()) return setMessage("Bitte eine Test-E-Mail angeben.");
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 || "Test fehlgeschlagen.")); }
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Test failed." : "Test fehlgeschlagen."))); }
finally { setTesting(false); }
};
@@ -52,31 +54,31 @@ export default function AdminSettingsModal({ open, onClose }) {
<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 }}>Admin Settings</div><div style={{ color: stylesTokens.textDim, fontSize: 12, marginTop: 3 }}>SMTP und Einladungen konfigurieren</div></div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"></button>
<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}>SMTP-Server<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}>Port<input type="number" value={form.smtp_port} onChange={(e) => setField("smtp_port", e.target.value)} style={styles.input} /></label>
<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}>SMTP-Benutzername<input value={form.smtp_username} onChange={(e) => setField("smtp_username", e.target.value)} placeholder="optional" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>Passwort<input type="password" value={form.smtp_password} onChange={(e) => setField("smtp_password", e.target.value)} placeholder={configured ? "•••••••• gespeichert" : "optional"} style={styles.input} /></label>
<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}>Absender-E-Mail<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}>Absendername<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label>
<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}>App-URL für Einladungslinks<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)} /> STARTTLS verwenden (empfohlen)</label>
<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 }}>Test-E-Mail<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="du@example.com" style={styles.input} /></label>
<button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? "Sende …" : "Test senden"}</button>
<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 ? "Speichern …" : "Einstellungen speichern"}</button>
<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 { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
const { language, t } = useLanguage();
if (!chipOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("whoHasCard")}</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</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}>
{chips.map((item) => (
@@ -27,12 +29,12 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip,
{!chips.length && (
<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 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>
+5 -3
View File
@@ -2,8 +2,10 @@ import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { THEMES } from "../styles/themes";
import { useLanguage } from "../i18n";
export default function DesignModal({ open, onClose, themeKey, onSelect }) {
const { language, t } = useLanguage();
if (!open) return null;
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.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Design ändern</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("changeDesign")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
<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 style={{ marginTop: 12, display: "grid", gap: 10 }}>
+18 -16
View File
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GamePickerCard({
games,
@@ -17,6 +18,7 @@ export default function GamePickerCard({
chipCount = 0,
onStartGame,
}) {
const { language, t } = useLanguage();
const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState("");
@@ -52,14 +54,14 @@ export default function GamePickerCard({
const handleStartGame = async () => {
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);
setStartError("");
try {
await onStartGame();
} 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 {
setStarting(false);
}
@@ -68,7 +70,7 @@ export default function GamePickerCard({
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div>
<div style={styles.sectionHeader}>{t("game")}</div>
<div style={styles.cardBody}>
<select
@@ -83,8 +85,8 @@ export default function GamePickerCard({
))}
</select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe">
Hilfe
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
{t("help")}
</button>
</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>
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 15 }}>
Lobby
{t("lobby")}
</div>
<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 style={{ textAlign: "right", color: stylesTokens.textGold, fontWeight: 1000 }}>
<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>
@@ -138,7 +140,7 @@ export default function GamePickerCard({
{members.length > 0 ? members.map((m, index) => {
const isMe = String(me?.id) === 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 (
<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>
@@ -150,7 +152,7 @@ export default function GamePickerCard({
);
}) : (
<div style={{ padding: "10px 4px", color: stylesTokens.textDim, fontSize: 13 }}>
Warte auf Spieler
{language === "en" ? "Waiting for players …" : "Warte auf Spieler …"}
</div>
)}
</div>
@@ -162,15 +164,15 @@ export default function GamePickerCard({
style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }}
disabled={starting || members.length < 2}
>
{starting ? "Spiel wird gestartet …" : "▶ Spiel starten"}
{starting ? (language === "en" ? "Starting game …" : "Spiel wird gestartet …") : `${t("startGame")}`}
</button>
<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 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>
)}
@@ -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 }}>
{finished
? `🏆 Spiel beendet${winnerName ? ` · Sieger: ${winnerName}` : ""}`
: `Spiel läuft · ${chipCount} Spieler-Chips erstellt`}
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
: `${t("started")} · ${chipCount} ${t("chipsCreated")}`}
</div>
)}
@@ -198,7 +200,7 @@ export default function GamePickerCard({
gap: 10,
}}
>
<div>Spieler</div>
<div>{t("players")}</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length}
</div>
@@ -2,8 +2,10 @@ import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GameStartCelebration({ open, onClose }) {
const { t, language } = useLanguage();
useEffect(() => {
if (!open) return;
@@ -29,14 +31,14 @@ export default function GameStartCelebration({ open, onClose }) {
if (!open) return null;
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-rune"></div>
<div className="hp-start-kicker">Die Ermittlungen beginnen</div>
<div className="hp-start-title">Spiel gestartet</div>
<div className="hp-start-subtitle">Möge der beste Detektiv gewinnen.</div>
<div className="hp-start-kicker">{t("investigationsBegin")}</div>
<div className="hp-start-title">{t("gameStarted")}</div>
<div className="hp-start-subtitle">{t("bestDetective")}</div>
<button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}>
Los geht&apos;s
{language === "en" ? "Lets go" : "Los geht's"}
</button>
</div>
</div>,
+39 -41
View File
@@ -1,122 +1,121 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function HelpModal({ open, onClose }) {
const { language, t } = useLanguage();
if (!open) return null;
const en = language === "en";
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe & Spielablauf</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("helpTitle")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
<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}>
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.
{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.</>}
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>👑</span>
<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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
Der Start ist ab <b>zwei Spielern</b> möglich. Danach können keine weiteren Spieler
beitreten.
{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.</>}
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>SNE</span>
<div>
Beim Start werden die Spieler-Chips automatisch erstellt. Beispiel: Sascha Nesterovic
wird zu <b>SNE</b>.
{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>.</>}
</div>
</div>
</div>
<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}>
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.
{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.</>}
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<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 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}>
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 style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span>
<div><b>Grün</b> = bestätigt / vorhanden</div>
<div><b>{en ? "Green" : "Grün"}</b> = {en ? "confirmed / present" : "bestätigt / vorhanden"}</div>
</div>
<div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}></span>
<div><b>Rot</b> = ausgeschlossen / nicht vorhanden</div>
<div><b>{en ? "Red" : "Rot"}</b> = {en ? "excluded / not present" : "ausgeschlossen / nicht vorhanden"}</div>
</div>
<div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(140,140,140,0.14)", color: "rgba(233,216,166,0.85)" }}>?</span>
<div><b>Grau</b> = unsicher / vielleicht</div>
<div><b>{en ? "Grey" : "Grau"}</b> = {en ? "uncertain / maybe" : "unsicher / vielleicht"}</div>
</div>
<div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}></span>
<div><b>Leer</b> = noch nicht bewertet</div>
<div><b>{en ? "Empty" : "Leer"}</b> = {en ? "not evaluated yet" : "noch nicht bewertet"}</div>
</div>
</div>
<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}>
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 style={styles.helpList}>
<div style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
@@ -126,57 +125,56 @@ export default function HelpModal({ open, onClose }) {
<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}>
Nur der Host kann den Sieger festlegen. Sobald ein Sieger gespeichert wurde, gilt das Spiel
als beendet:
{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:"}
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 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}>
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 style={styles.helpList}>
<div style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div><b>Logout</b> = abmelden</div>
<div><b>{t("logout")}</b> = {en ? "sign out" : "abmelden"}</div>
</div>
<div style={styles.helpListRow}>
<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>Admin Settings</b> = SMTP konfigurieren und Einladungen ohne vorgegebenes Passwort versenden</div>
<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 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>
+8 -6
View File
@@ -2,8 +2,10 @@ 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("");
@@ -13,8 +15,8 @@ export default function InvitePage({ token }) {
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("Das Passwort muss mindestens 8 Zeichen haben.");
if (password !== confirm) return setMessage("Die Passwörter stimmen nicht überein.");
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); }
@@ -25,14 +27,14 @@ export default function InvitePage({ token }) {
<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 }}>Deine Einladung</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}>Passwort setzen<input autoFocus type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mindestens 8 Zeichen" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>Passwort bestätigen<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="Passwort wiederholen" style={styles.input} /></label>
<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 ? "Wird aktiviert …" : "Einladung annehmen"}</button>
<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>
+6 -4
View File
@@ -2,8 +2,10 @@
import React, { useEffect, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function JoinGameModal({ open, onClose, onJoin }) {
const { language, t } = useLanguage();
const [code, setCode] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
@@ -19,13 +21,13 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
const doJoin = async () => {
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);
setMsg("");
try {
await onJoin(c);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setBusy(false);
}
};
@@ -34,8 +36,8 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("joinGame")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
+16 -14
View File
@@ -1,5 +1,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { useLanguage } from "../i18n";
export default function LoginPage({
loginEmail,
@@ -22,6 +23,7 @@ export default function LoginPage({
setupSaving,
doSetup,
}) {
const { language, t } = useLanguage();
const isSetup = setupRequired === true;
return (
@@ -37,10 +39,10 @@ export default function LoginPage({
<div style={styles.loginSubtitle}>
{setupRequired === null
? "Initialisiere Anwendung …"
? (language === "en" ? "Initializing application …" : "Initialisiere Anwendung …")
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
? (language === "en" ? "Set up the first administrator" : "Richte den ersten Administrator ein")
: (language === "en" ? "Log in to open your Cluedo sheet" : "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen")}
</div>
{isSetup ? (
@@ -49,7 +51,7 @@ export default function LoginPage({
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name"
placeholder={t("displayName")}
style={styles.loginInput}
autoComplete="name"
/>
@@ -59,7 +61,7 @@ export default function LoginPage({
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email"
placeholder={language === "en" ? "Admin email" : "Admin-E-Mail"}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
@@ -70,7 +72,7 @@ export default function LoginPage({
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)"
placeholder={t("passwordMin")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
@@ -81,7 +83,7 @@ export default function LoginPage({
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password"
placeholder={t("confirmPassword")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
@@ -91,7 +93,7 @@ export default function LoginPage({
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<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>
</div>
) : (
@@ -100,7 +102,7 @@ export default function LoginPage({
<input
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email"
placeholder={t("email")}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
@@ -112,7 +114,7 @@ export default function LoginPage({
<input
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort"
placeholder={t("password")}
type={showPw ? "text" : "password"}
style={styles.inputInRow}
autoComplete="current-password"
@@ -121,8 +123,8 @@ export default function LoginPage({
type="button"
onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
title={showPw ? "Verstecken" : "Anzeigen"}
aria-label={showPw ? (language === "en" ? "Hide password" : "Passwort verstecken") : (language === "en" ? "Show password" : "Passwort anzeigen")}
title={showPw ? (language === "en" ? "Hide" : "Verstecken") : (language === "en" ? "Show" : "Anzeigen")}
>
{showPw ? "🙈" : "👁"}
</button>
@@ -130,13 +132,13 @@ export default function LoginPage({
</div>
<button onClick={doLogin} style={styles.loginBtn}>
Anmelden
{t("login")}
</button>
</div>
)}
<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>
+24 -22
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function NewGameModal({
open,
@@ -13,6 +14,7 @@ export default function NewGameModal({
gameFinished = false,
hasGame = false,
}) {
const { language, t } = useLanguage();
// modes: running | choice | create | join
const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState("");
@@ -53,7 +55,7 @@ export default function NewGameModal({
setCreated({ code: res.code });
setMode("create");
} 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());
onClose();
} 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 {
await navigator.clipboard.writeText(text || "");
showToast(okMsg);
} 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.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Spiel
{t("game")}
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -116,7 +118,7 @@ export default function NewGameModal({
{mode === "running" && (
<>
<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
@@ -131,7 +133,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
{t("gameCode")}
</div>
<div
@@ -150,19 +152,19 @@ export default function NewGameModal({
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
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>
</div>
<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 style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
{language === "en" ? "Done" : "Fertig"}
</button>
</div>
</>
@@ -173,15 +175,15 @@ export default function NewGameModal({
{mode === "choice" && (
<>
<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>
<button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen
{t("createGame")}
</button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten
{t("joinGame")}
</button>
</>
)}
@@ -189,7 +191,7 @@ export default function NewGameModal({
{mode === "join" && (
<>
<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>
<input
@@ -202,10 +204,10 @@ export default function NewGameModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück
{language === "en" ? "Back" : "Zurück"}
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten
{t("join")}
</button>
</div>
</>
@@ -214,7 +216,7 @@ export default function NewGameModal({
{mode === "create" && created && (
<>
<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
@@ -229,7 +231,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
{t("gameCode")}
</div>
<div
@@ -245,13 +247,13 @@ export default function NewGameModal({
</div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren
{t("copy")} {language === "en" ? "code" : "Code"}
</button>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
{language === "en" ? "Done" : "Fertig"}
</button>
</div>
</>
+9 -7
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function PasswordModal({
pwOpen,
@@ -13,14 +14,15 @@ export default function PasswordModal({
pwSaving,
savePassword,
}) {
const { language, t } = useLanguage();
if (!pwOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("setPasswordTitle")}</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -29,7 +31,7 @@ export default function PasswordModal({
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort"
placeholder={language === "en" ? "New password" : "Neues Passwort"}
type="password"
style={styles.input}
autoFocus
@@ -37,7 +39,7 @@ export default function PasswordModal({
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen"
placeholder={t("passwordAgain")}
type="password"
style={styles.input}
onKeyDown={(e) => {
@@ -49,15 +51,15 @@ export default function PasswordModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen
{t("cancel")}
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"}
{pwSaving ? (language === "en" ? "Saving…" : "Speichern") : t("save")}
</button>
</div>
<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>
+4 -2
View File
@@ -2,6 +2,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function SheetSection({
title,
@@ -12,6 +13,7 @@ export default function SheetSection({
displayTag,
readOnly = false,
}) {
const { t } = useLanguage();
const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg;
@@ -79,7 +81,7 @@ export default function SheetSection({
opacity: effectiveStatus === 1 ? 0.8 : 1,
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}
</div>
@@ -100,7 +102,7 @@ export default function SheetSection({
onClick={() => !readOnly && onToggleTag(e)}
style={styles.tagBtn}
disabled={readOnly}
title={readOnly ? "Spiel beendet Notizzettel ist schreibgeschützt" : "— → i → m → s.(Chip) → —"}
title={readOnly ? t("readOnly") : t("cycleNote")}
>
{displayTag(e)}
</button>
+9 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
function Tile({ label, value, sub }) {
return (
@@ -48,6 +49,7 @@ function Tile({ label, value, sub }) {
}
export default function StatsModal({ open, onClose, me, stats, loading, error }) {
const { language, t } = useLanguage();
if (!open) return null;
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.modalHeader}>
<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 }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -71,7 +73,7 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<div style={{ marginTop: 12 }}>
{loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik
{language === "en" ? "Loading statistics…" : "Lade Statistik…"}
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
@@ -83,10 +85,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
gap: 10,
}}
>
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
<Tile label={language === "en" ? "Games played" : "Gespielte Spiele"} value={stats?.played ?? 0} />
<Tile label={language === "en" ? "Wins" : "Siege"} value={stats?.wins ?? 0} />
<Tile label={language === "en" ? "Losses" : "Verluste"} value={stats?.losses ?? 0} />
<Tile label={language === "en" ? "Win rate" : "Siegerate"} value={`${stats?.winrate ?? 0}%`} sub={language === "en" ? "finished games only" : "nur beendete Spiele"} />
</div>
)}
</div>
+22 -9
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function TopBar({
me,
@@ -14,9 +15,10 @@ export default function TopBar({
doLogout,
onOpenNewGame,
}) {
const { toggleLanguage, t } = useLanguage();
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
const isAdmin = me?.role === "admin";
const roleLabel = isAdmin ? "Admin" : "User";
const roleLabel = isAdmin ? t("admin") : t("user");
return (
<div className="hp-topbar" style={styles.topBar}>
@@ -33,7 +35,7 @@ export default function TopBar({
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
title={t("user") + " menu"}
>
<span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span>
<span>{roleLabel}</span>
@@ -61,7 +63,7 @@ export default function TopBar({
}}
style={styles.userDropdownItem}
>
Statistik
{t("statistics")}
</button>
{isAdmin && (
@@ -74,7 +76,7 @@ export default function TopBar({
}}
style={styles.userDropdownItem}
>
Admin Dashboard
{t("adminDashboard")}
</button>
<button
onClick={() => {
@@ -83,7 +85,7 @@ export default function TopBar({
}}
style={styles.userDropdownItem}
>
Admin Settings
{t("adminSettings")}
</button>
</>
)}
@@ -91,15 +93,26 @@ export default function TopBar({
<div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
{t("setPassword")}
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
{t("changeDesign")}
</button>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
toggleLanguage();
setUserMenuOpen(false);
}}
style={styles.userDropdownItem}
title={`${t("changeLanguage")}: ${t("switchTo")}`}
>
🌐 {t("changeLanguage")}
</button>
<button
onClick={() => {
setUserMenuOpen(false);
@@ -107,14 +120,14 @@ export default function TopBar({
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
Logout
{t("logout")}
</button>
</div>
)}
</div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game
{t("newGame")}
</button>
</div>
</div>
+4 -2
View File
@@ -1,5 +1,6 @@
import React from "react";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
/**
* Props:
@@ -7,6 +8,7 @@ import { stylesTokens } from "../styles/theme";
* - winnerEmail: string | null (legacy fallback)
*/
export default function WinnerBadge({ winner, winnerEmail }) {
const { language } = useLanguage();
const name =
(winner?.display_name || "").trim() ||
(winner?.email || "").trim() ||
@@ -34,13 +36,13 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger:
{language === "en" ? "Winner:" : "Sieger:"}
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div>
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt
{language === "en" ? "selected" : "festgelegt"}
</div>
</div>
);
+6 -4
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCard({
isHost,
@@ -9,12 +10,13 @@ export default function WinnerCard({
setWinnerUserId,
onSave,
}) {
const { language, t } = useLanguage();
if (!isHost) return null;
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.sectionHeader}>{t("winner")}</div>
<div style={styles.cardBody}>
<select
@@ -22,7 +24,7 @@ export default function WinnerCard({
onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }}
>
<option value=""> kein Sieger </option>
<option value=""> {language === "en" ? "no winner" : "kein Sieger"} </option>
{members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
@@ -34,12 +36,12 @@ export default function WinnerCard({
</select>
<button onClick={onSave} style={styles.primaryBtn}>
Speichern
{t("save")}
</button>
</div>
<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>
@@ -2,8 +2,10 @@ import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCelebration({ open, winnerName, onClose }) {
const { language } = useLanguage();
useEffect(() => {
if (!open) return;
@@ -146,15 +148,15 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
lineHeight: 1.25,
}}
>
Spieler{" "}
{language === "en" ? "Player " : "Spieler "}
<span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"}
</span>{" "}
hat die richtige Lösung!
{language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
</div>
<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 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 ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { LanguageProvider } from "./i18n.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register";
@@ -21,7 +22,9 @@ async function bootstrap() {
} catch {}
// App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
ReactDOM.createRoot(document.getElementById("root")).render(
<LanguageProvider><App /></LanguageProvider>
);
// Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000;