diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 71c0c3b..52fa145 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 || [] }, ] : []; diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index e259018..2cb97f3 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -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
-
Admin Dashboard
-
Benutzer, Rollen und Zugangsdaten verwalten
+
{t("adminDashboard")}
+
{t("adminDashboardSubtitle")}
-
Vorhandene User ({users.length})
- +
{t("existingUsers")} ({users.length})
+
{notice &&
{notice}
} @@ -132,8 +134,8 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
{user.role}
{user.disabled ? "disabled" : "active"}
- - + +
))} @@ -146,21 +148,21 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
e.stopPropagation()}>
-
{editingUser ? "User bearbeiten" : "Neuen User anlegen"}
-
{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}
+
{editingUser ? t("editUser") : t("newUser")}
+
{editingUser ? (language === "en" ? "Update profile and credentials" : "Profil und Zugangsdaten aktualisieren") : (language === "en" ? "Create a new user account" : "Ein neues Benutzerkonto erstellen")}
- +
- - - - - {editingUser && } + + + + + {editingUser && } {msg &&
{msg}
} - {!editingUser &&
Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.
} - + {!editingUser &&
{t("inviteNotice")}
} +
, diff --git a/frontend/src/components/AdminSettingsModal.jsx b/frontend/src/components/AdminSettingsModal.jsx index 47bd93f..d9aeb00 100644 --- a/frontend/src/components/AdminSettingsModal.jsx +++ b/frontend/src/components/AdminSettingsModal.jsx @@ -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 }) {
e.stopPropagation()}>
-
Admin Settings
SMTP und Einladungen konfigurieren
- +
{t("adminSettings")}
{t("adminSettingsSubtitle")}
+
- - + +
- - + +
- - + +
- - + +
- - + +
{message &&
{message}
} - +
, document.body diff --git a/frontend/src/components/ChipModal.jsx b/frontend/src/components/ChipModal.jsx index f37f3f9..27c3891 100644 --- a/frontend/src/components/ChipModal.jsx +++ b/frontend/src/components/ChipModal.jsx @@ -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 (
e.stopPropagation()}>
-
Wer hat die Karte?
-
-
Chip auswählen:
+
{t("selectChip")}:
{chips.map((item) => ( @@ -27,12 +29,12 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, {!chips.length && (
- 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."}
)}
- Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von s zurück auf —. + {language === "en" ? <>Tip: Tap the note button again to go from s back to —. : <>Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von s zurück auf —.}
diff --git a/frontend/src/components/DesignModal.jsx b/frontend/src/components/DesignModal.jsx index 84a2ca9..fd0dce1 100644 --- a/frontend/src/components/DesignModal.jsx +++ b/frontend/src/components/DesignModal.jsx @@ -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 }) {
e.stopPropagation()}>
-
Design ändern
-
- Wähle dein Theme: + {language === "en" ? "Choose your theme:" : "Wähle dein Theme:"}
diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 7cb06f4..a78662b 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -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 (
-
Spiel
+
{t("game")}
setPassword(e.target.value)} placeholder="Mindestens 8 Zeichen" style={styles.input} /> - + + {message &&
{message}
} - + :
{message || "Einladung wird geprüft …"}
} diff --git a/frontend/src/components/JoinGameModal.jsx b/frontend/src/components/JoinGameModal.jsx index 52486b0..897fe83 100644 --- a/frontend/src/components/JoinGameModal.jsx +++ b/frontend/src/components/JoinGameModal.jsx @@ -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 }) {
e.stopPropagation()}>
-
Spiel beitreten
-
diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx index 070eb33..9897a16 100644 --- a/frontend/src/components/LoginPage.jsx +++ b/frontend/src/components/LoginPage.jsx @@ -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({
{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")}
{isSetup ? ( @@ -49,7 +51,7 @@ export default function LoginPage({ setSetupDisplayName(e.target.value)} - placeholder="Display name" + placeholder={t("displayName")} style={styles.loginInput} autoComplete="name" /> @@ -59,7 +61,7 @@ export default function LoginPage({ 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({ 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({ 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 &&
{setupError}
}
) : ( @@ -100,7 +102,7 @@ export default function LoginPage({ setLoginEmail(e.target.value)} - placeholder="Email" + placeholder={t("email")} style={styles.loginInput} inputMode="email" autoComplete="username" @@ -112,7 +114,7 @@ export default function LoginPage({ 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 ? "🙈" : "👁"} @@ -130,13 +132,13 @@ export default function LoginPage({
)}
- 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."}
diff --git a/frontend/src/components/NewGameModal.jsx b/frontend/src/components/NewGameModal.jsx index 04d291e..69ac42e 100644 --- a/frontend/src/components/NewGameModal.jsx +++ b/frontend/src/components/NewGameModal.jsx @@ -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({
e.stopPropagation()}>
- Spiel + {t("game")}
-
@@ -116,7 +118,7 @@ export default function NewGameModal({ {mode === "running" && ( <>
- Das Spiel läuft noch. Hier ist der Join-Code: + {language === "en" ? <>The game is in progress. Here is the join code: : <>Das Spiel läuft noch. Hier ist der Join-Code:}
- Spiel-Code + {t("gameCode")}
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"}
- 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."}
@@ -173,15 +175,15 @@ export default function NewGameModal({ {mode === "choice" && ( <>
- Willst du ein Spiel erstellen oder einem Spiel beitreten? + {language === "en" ? <>Would you like to create a game or join one? : <>Willst du ein Spiel erstellen oder einem Spiel beitreten?}
)} @@ -189,7 +191,7 @@ export default function NewGameModal({ {mode === "join" && ( <>
- Gib den Code ein: + {language === "en" ? <>Enter the code: : <>Gib den Code ein:}
@@ -214,7 +216,7 @@ export default function NewGameModal({ {mode === "create" && created && ( <>
- 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:"}
- Spiel-Code + {t("gameCode")}
diff --git a/frontend/src/components/PasswordModal.jsx b/frontend/src/components/PasswordModal.jsx index 75f563c..d0bc148 100644 --- a/frontend/src/components/PasswordModal.jsx +++ b/frontend/src/components/PasswordModal.jsx @@ -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 (
e.stopPropagation()}>
-
Passwort setzen
-
@@ -29,7 +31,7 @@ export default function PasswordModal({ 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({ 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({
- Hinweis: Mindestens 8 Zeichen empfohlen. + {language === "en" ? "Note: At least 8 characters are recommended." : "Hinweis: Mindestens 8 Zeichen empfohlen."}
diff --git a/frontend/src/components/SheetSection.jsx b/frontend/src/components/SheetSection.jsx index dc72bed..39c947c 100644 --- a/frontend/src/components/SheetSection.jsx +++ b/frontend/src/components/SheetSection.jsx @@ -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}
@@ -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)} diff --git a/frontend/src/components/StatsModal.jsx b/frontend/src/components/StatsModal.jsx index db6b91d..6ad1c0c 100644 --- a/frontend/src/components/StatsModal.jsx +++ b/frontend/src/components/StatsModal.jsx @@ -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 })
e.stopPropagation()}>
-
Statistik
+
{t("statistics")}
{displayName}
-
@@ -71,7 +73,7 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
{loading ? (
- Lade Statistik… + {language === "en" ? "Loading statistics…" : "Lade Statistik…"}
) : error ? (
{error}
@@ -83,10 +85,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error }) gap: 10, }} > - - - - + + + +
)}
diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index fafaf79..522e7d7 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -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 (
@@ -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"} > {isAdmin ? "🛡️" : "👤"} {roleLabel} @@ -61,7 +63,7 @@ export default function TopBar({ }} style={styles.userDropdownItem} > - Statistik + {t("statistics")} {isAdmin && ( @@ -74,7 +76,7 @@ export default function TopBar({ }} style={styles.userDropdownItem} > - Admin Dashboard + {t("adminDashboard")} )} @@ -91,15 +93,26 @@ export default function TopBar({
+ +
)}
diff --git a/frontend/src/components/WinnerBadge.jsx b/frontend/src/components/WinnerBadge.jsx index c4fb1c0..f3608c0 100644 --- a/frontend/src/components/WinnerBadge.jsx +++ b/frontend/src/components/WinnerBadge.jsx @@ -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 }) {
🏆
- Sieger: + {language === "en" ? "Winner:" : "Sieger:"} {" "}{name}
- festgelegt + {language === "en" ? "selected" : "festgelegt"}
); diff --git a/frontend/src/components/WinnerCard.jsx b/frontend/src/components/WinnerCard.jsx index b76f588..6cd7229 100644 --- a/frontend/src/components/WinnerCard.jsx +++ b/frontend/src/components/WinnerCard.jsx @@ -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 (
-
Sieger
+
{t("winner")}
- 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."}
diff --git a/frontend/src/components/WinnerCelebration.jsx b/frontend/src/components/WinnerCelebration.jsx index ec2244c..f02c368 100644 --- a/frontend/src/components/WinnerCelebration.jsx +++ b/frontend/src/components/WinnerCelebration.jsx @@ -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 "} {winnerName || "Unbekannt"} {" "} - hat die richtige Lösung! + {language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
- Fall gelöst. Respekt. ✨ + {language === "en" ? "Case solved. Respect. ✨" : "Fall gelöst. Respekt. ✨"}
diff --git a/frontend/src/i18n.jsx b/frontend/src/i18n.jsx new file mode 100644 index 0000000..629e0cd --- /dev/null +++ b/frontend/src/i18n.jsx @@ -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 {children}; +} + +export function useLanguage() { + return useContext(LanguageContext) || { language: "de", toggleLanguage: () => {}, t: (key) => key }; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c0a0bc2..6e1807b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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(); + ReactDOM.createRoot(document.getElementById("root")).render( + + ); // Splash mind. 3 Sekunden anzeigen (3000ms) const MIN_SPLASH_MS = 3000;