Add English/German i18n for frontend

This commit is contained in:
2026-02-02 14:38:57 +01:00
parent fd13f29cda
commit 3050aa2265
4 changed files with 407 additions and 90 deletions

264
wwwroot/js/i18n.js Normal file
View File

@@ -0,0 +1,264 @@
const translations = {
en: {
"lang.label": "Language",
"lang.en": "English",
"lang.de": "Deutsch",
"auth.loginTab": "Log in",
"auth.registerTab": "Register",
"auth.username": "Username",
"auth.password": "Password",
"auth.displayName": "Display name (shows to group)",
"auth.adminKey": "Admin key (optional)",
"auth.loginSubmit": "Log in",
"auth.registerSubmit": "Create account",
"auth.logout": "Logout",
"auth.welcome": "Welcome, {name}!",
"auth.defaultName": "Player",
"auth.loading": "Loading…",
"auth.needCredentials": "Username and password required",
"auth.invalidCredentials": "Invalid username or password",
"phase.suggest": "Suggest",
"phase.reveal": "Reveal",
"phase.vote": "Vote",
"phase.results": "Results",
"phase.loading": "Loading…",
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"suggest.title": "Suggest (up to 5)",
"suggest.hint": "Only you can see your suggestions until Reveal.",
"form.gameName": "Game name *",
"form.genre": "Genre",
"form.description": "Description",
"form.players": "Players",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot URL",
"form.youtube": "YouTube URL",
"form.gameUrl": "Game website URL",
"form.submit": "Submit",
"form.placeholder.description": "Short description",
"form.placeholder.gameName": "Game name *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot URL",
"form.placeholder.youtube": "YouTube URL",
"form.placeholder.gameUrl": "Game website URL",
"section.mySuggestions": "Your suggestions",
"section.allSuggestions": "All Suggestions",
"section.vote": "Vote 0-10",
"section.results": "Results",
"card.edit": "Edit",
"card.delete": "Delete",
"card.players": "Players: {min}{max}",
"card.site": "Site ↗",
"card.youtube": "YouTube ↗",
"card.openScreenshot": "Open screenshot",
"vote.saved": "Saved vote",
"results.rank": "Rank",
"results.game": "Game",
"results.author": "Author",
"results.votes": "Votes",
"results.avg": "Avg",
"results.total": "Total",
"results.links": "Links",
"results.link.site": "Site ↗",
"results.link.youtube": "YouTube ↗",
"admin.title": "Admin",
"admin.tools": "Admin tools",
"admin.setPhase": "Set phase",
"admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset",
"admin.phaseUpdated": "Phase updated",
"admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete",
"toast.unexpected": "Unexpected error",
"toast.registered": "Registered",
"toast.loggedIn": "Logged in",
"toast.suggestionAdded": "Suggestion added",
"toast.suggestionDeleted": "Suggestion deleted",
"toast.savedChanges": "Saved changes",
"toast.nameRequired": "Name required",
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
"modal.editTitle": "Edit game",
"modal.save": "Save changes",
"modal.cancel": "Cancel",
"modal.close": "Close",
"lightbox.close": "Close",
},
de: {
"lang.label": "Sprache",
"lang.en": "Englisch",
"lang.de": "Deutsch",
"auth.loginTab": "Anmelden",
"auth.registerTab": "Registrieren",
"auth.username": "Benutzername",
"auth.password": "Passwort",
"auth.displayName": "Anzeigename (für die Gruppe sichtbar)",
"auth.adminKey": "Admin-Schlüssel (optional)",
"auth.loginSubmit": "Anmelden",
"auth.registerSubmit": "Konto erstellen",
"auth.logout": "Abmelden",
"auth.welcome": "Willkommen, {name}!",
"auth.defaultName": "Spieler",
"auth.loading": "Lädt…",
"auth.needCredentials": "Benutzername und Passwort erforderlich",
"auth.invalidCredentials": "Ungültiger Benutzername oder Passwort",
"phase.suggest": "Vorschlagen",
"phase.reveal": "Enthüllen",
"phase.vote": "Bewerten",
"phase.results": "Ergebnisse",
"phase.loading": "Lädt…",
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"suggest.title": "Vorschlagen (bis zu 5)",
"suggest.hint": "Nur du siehst deine Vorschläge bis zur Enthüllung.",
"form.gameName": "Spielname *",
"form.genre": "Genre",
"form.description": "Beschreibung",
"form.players": "Spieler",
"form.min": "Min",
"form.max": "Max",
"form.screenshot": "Screenshot-URL",
"form.youtube": "YouTube-URL",
"form.gameUrl": "Spiel-Webseite",
"form.submit": "Absenden",
"form.placeholder.description": "Kurze Beschreibung",
"form.placeholder.gameName": "Spielname *",
"form.placeholder.genre": "Genre",
"form.placeholder.screenshot": "Screenshot-URL",
"form.placeholder.youtube": "YouTube-URL",
"form.placeholder.gameUrl": "Spiel-Webseite",
"section.mySuggestions": "Deine Vorschläge",
"section.allSuggestions": "Alle Vorschläge",
"section.vote": "Bewerten 0-10",
"section.results": "Ergebnisse",
"card.edit": "Bearbeiten",
"card.delete": "Löschen",
"card.players": "Spieler: {min}{max}",
"card.site": "Website ↗",
"card.youtube": "YouTube ↗",
"card.openScreenshot": "Screenshot öffnen",
"vote.saved": "Stimme gespeichert",
"results.rank": "Rang",
"results.game": "Spiel",
"results.author": "Autor",
"results.votes": "Stimmen",
"results.avg": "Durchschn.",
"results.total": "Gesamt",
"results.links": "Links",
"results.link.site": "Website ↗",
"results.link.youtube": "YouTube ↗",
"admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge",
"admin.setPhase": "Phase setzen",
"admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung",
"admin.phaseUpdated": "Phase aktualisiert",
"admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"toast.unexpected": "Unerwarteter Fehler",
"toast.registered": "Registriert",
"toast.loggedIn": "Angemeldet",
"toast.suggestionAdded": "Vorschlag hinzugefügt",
"toast.suggestionDeleted": "Vorschlag gelöscht",
"toast.savedChanges": "Änderungen gespeichert",
"toast.nameRequired": "Name erforderlich",
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
"modal.editTitle": "Spiel bearbeiten",
"modal.save": "Änderungen speichern",
"modal.cancel": "Abbrechen",
"modal.close": "Schließen",
"lightbox.close": "Schließen",
}
};
const storageKey = "app_lang";
const defaultLang = "en";
let currentLang = defaultLang;
const listeners = [];
function interpolate(template, params = {}) {
return template.replace(/\{(\w+)\}/g, (_, key) => (params[key] ?? `{${key}}`));
}
function t(key, params) {
const fallback = translations[defaultLang][key] ?? key;
const phrase = translations[currentLang]?.[key] ?? fallback;
return interpolate(phrase, params);
}
function detectLanguage() {
const stored = localStorage.getItem(storageKey);
if (stored && translations[stored]) return stored;
const nav = navigator.language?.slice(0, 2);
if (nav && translations[nav]) return nav;
return defaultLang;
}
function applyTranslations(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.dataset.i18n;
const attrs = (el.dataset.i18nAttr || "")
.split(",")
.map((a) => a.trim())
.filter(Boolean);
const text = t(key);
if (attrs.length === 0) {
el.textContent = text;
} else {
attrs.forEach((attr) => el.setAttribute(attr, text));
}
});
}
function notify() {
listeners.forEach((fn) => fn(currentLang));
}
function setLanguage(lang) {
if (!translations[lang]) lang = defaultLang;
currentLang = lang;
localStorage.setItem(storageKey, lang);
document.documentElement.lang = lang;
applyTranslations();
notify();
}
function getLanguage() {
return currentLang;
}
function initI18n() {
currentLang = detectLanguage();
document.documentElement.lang = currentLang;
applyTranslations();
notify();
return currentLang;
}
function onLanguageChange(fn) {
listeners.push(fn);
}
export { t, setLanguage, getLanguage, initI18n, applyTranslations, onLanguageChange, translations };