diff --git a/wwwroot/app.js b/wwwroot/app.js index 18e54d2..683ee04 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,4 +1,7 @@ import { api, adminApi } from "./js/api.js"; +import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js"; + +initI18n(); const state = { isAuthenticated: false, @@ -76,7 +79,7 @@ function handleAuthError(err) { setAuthUI(false); return true; } - toast(err?.message || "Unexpected error", true); + toast(err?.message || t("toast.unexpected"), true); return false; } @@ -128,7 +131,8 @@ async function loadResults() { } function renderPhasePill() { - $("phase-pill").textContent = state.phase || "Loading…"; + const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null; + $("phase-pill").textContent = phaseKey ? t(`phase.${phaseKey}`) : t("phase.loading"); document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden")); const viewMap = { Suggest: "suggest-view", @@ -146,14 +150,18 @@ function renderPhasePill() { function renderCounts() { if (!state.counts) return; - $("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`; + $("counts").textContent = t("counts.format", { + players: state.counts.players, + suggestions: state.counts.suggestions, + votes: state.counts.votes + }); } function renderWelcome() { const el = $("welcome-text"); if (!el) return; - const name = state.me?.displayName?.trim() || state.me?.username || "Player"; - el.textContent = `Welcome, ${name}!`; + const name = state.me?.displayName?.trim() || state.me?.username || t("auth.defaultName"); + el.textContent = t("auth.welcome", { name }); } function renderMySuggestions() { @@ -202,7 +210,7 @@ function renderVotes() { const score = Number(e.target.value); try { await api.vote(suggestionId, score); - toast("Saved vote"); + toast(t("vote.saved")); await loadVoteData(); } catch (err) { toast(err.message, true); @@ -233,13 +241,13 @@ function renderResults() { table.innerHTML = ` - Rank - Game - Author - Votes - Avg - Total - Links + ${t("results.rank")} + ${t("results.game")} + ${t("results.author")} + ${t("results.votes")} + ${t("results.avg")} + ${t("results.total")} + ${t("results.links")} @@ -261,8 +269,8 @@ function renderResults() { ${r.average.toFixed(1)} ${r.total} - ${r.gameUrl ? `Site ↗
` : ''} - ${r.youtubeUrl ? `YouTube ↗` : ''} + ${r.gameUrl ? `${t("results.link.site")}
` : ''} + ${r.youtubeUrl ? `${t("results.link.youtube")}` : ''} `; tbody.appendChild(row); @@ -279,22 +287,45 @@ function setupHandlers() { }); setAuthMode(state.authMode); + const langSelect = $("language-select"); + if (langSelect) { + langSelect.value = getLanguage(); + langSelect.addEventListener("change", () => setLanguage(langSelect.value)); + } + + onLanguageChange(() => { + if (langSelect) langSelect.value = getLanguage(); + renderWelcome(); + renderPhasePill(); + renderCounts(); + renderMySuggestions(); + renderAllSuggestions(); + if (state.phase === "Vote") { + renderVotes(); + state.votesRendered = true; + syncVoteScores(); + } + if (state.phase === "Results") { + renderResults(); + } + }); + const loginForm = $("login-form"); if (loginForm) { loginForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = $("login-username").value.trim(); const password = $("login-password").value; - if (!username || !password) return toast("Username and password required", true); + if (!username || !password) return toast(t("auth.needCredentials"), true); try { await api.login({ username, password }); setSavedUsername(username); state.isAuthenticated = true; setAuthUI(true); await refreshPhaseData(); - toast("Logged in"); + toast(t("toast.loggedIn")); } catch (err) { - if (err?.status === 401) return toast("Invalid username or password", true); + if (err?.status === 401) return toast(t("auth.invalidCredentials"), true); if (handleAuthError(err)) return; } }); @@ -308,14 +339,14 @@ function setupHandlers() { const password = $("register-password").value; const displayName = $("register-displayName").value.trim(); const adminKey = $("register-adminkey").value.trim(); - if (!username || !password) return toast("Username and password required", true); + if (!username || !password) return toast(t("auth.needCredentials"), true); try { await api.register({ username, password, displayName, adminKey }); setSavedUsername(username); state.isAuthenticated = true; setAuthUI(true); await refreshPhaseData(); - toast("Registered"); + toast(t("toast.registered")); } catch (err) { if (handleAuthError(err)) return; toast(err.message, true); @@ -327,14 +358,14 @@ function setupHandlers() { e.preventDefault(); const form = e.target; const data = normalizeSuggestionForm(new FormData(form)); - if (!data.name) return toast("Name required", true); + if (!data.name) return toast(t("toast.nameRequired"), true); if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { - return toast("Screenshot URL must be http(s) and end with an image file.", true); + return toast(t("toast.invalidImageUrl"), true); } try { await api.createSuggestion(data); form.reset(); - toast("Suggestion added"); + toast(t("toast.suggestionAdded")); await loadSuggestData(); } catch (err) { toast(err.message, true); @@ -345,7 +376,7 @@ function setupHandlers() { const phase = $("phase-select").value; try { await adminApi.setPhase(phase); - toast("Phase updated"); + toast(t("admin.phaseUpdated")); state.prevPhase = state.phase; state.phase = phase; state.votesRendered = false; @@ -363,8 +394,8 @@ function setupHandlers() { }); phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; }); - $("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete")); - $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete")); + $("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone"))); + $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone"))); const logoutBtn = $("logout"); if (logoutBtn) { @@ -432,7 +463,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal card.className = "game-card"; const hasImage = !!s.screenshotUrl; const visual = hasImage - ? `` + ? `` : `
`; card.innerHTML = ` ${visual} @@ -440,16 +471,16 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal

${s.name}

- ${s.gameUrl ? `Site ↗` : ""} - ${s.youtubeUrl ? `YouTube ↗` : ""} + ${s.gameUrl ? `${t("card.site")}` : ""} + ${s.youtubeUrl ? `${t("card.youtube")}` : ""} ${showAuthor && s.author ? `${s.author}` : ""} - ${allowEdit ? `` : ""} - ${allowDelete ? `` : ""} + ${allowEdit ? `` : ""} + ${allowDelete ? `` : ""}
${s.genre ? `

${s.genre}

` : ""} ${s.description ? `

${s.description}

` : ""} - ${(s.minPlayers || s.maxPlayers) ? `

Players: ${s.minPlayers ?? "?"}–${s.maxPlayers ?? "?"}

` : ""} + ${(s.minPlayers || s.maxPlayers) ? `

${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}

` : ""} `; if (hasImage) { @@ -465,7 +496,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal del.addEventListener("click", async () => { try { await api.deleteSuggestion(s.id); - toast("Suggestion deleted"); + toast(t("toast.suggestionDeleted")); await loadSuggestData(); } catch (err) { toast(err.message, true); @@ -481,35 +512,35 @@ function openEditModal(s) { overlay.innerHTML = `
-

Edit game

- +

${t("modal.editTitle")}

+
- - - + + +
- Players + ${t("form.players")}
- - - + + +
- - + +
@@ -529,12 +560,12 @@ function openEditModal(s) { e.preventDefault(); const data = normalizeSuggestionForm(new FormData(form)); if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { - return toast("Screenshot URL must be http(s) and end with an image file.", true); + return toast(t("toast.invalidImageUrl"), true); } - if (!data.name?.trim()) return toast("Name required", true); + if (!data.name?.trim()) return toast(t("toast.nameRequired"), true); try { await api.updateSuggestion(s.id, data); - toast("Saved changes"); + toast(t("toast.savedChanges")); close(); await refreshPhaseData(); } catch (err) { @@ -551,7 +582,7 @@ function openLightbox(url, title) { overlay.className = "lightbox"; overlay.innerHTML = ` diff --git a/wwwroot/index.html b/wwwroot/index.html index cc6d04a..9f790c0 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -9,52 +9,59 @@ +
+ + +
- Welcome! - Logout + Welcome! + Logout
- Loading… + Loading…
@@ -64,90 +71,90 @@
- + diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js new file mode 100644 index 0000000..82d8ca6 --- /dev/null +++ b/wwwroot/js/i18n.js @@ -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 }; diff --git a/wwwroot/styles.css b/wwwroot/styles.css index 11d2287..2d93b3a 100644 --- a/wwwroot/styles.css +++ b/wwwroot/styles.css @@ -13,6 +13,21 @@ gap: 16px; } +.lang-switch { + align-self: flex-end; + display: flex; + align-items: center; + gap: 8px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid #1f2937; + border-radius: 10px; + padding: 6px 10px; + box-shadow: 0 10px 24px rgba(0,0,0,0.25); + font-size: 14px; +} +.lang-switch label { color: #9ca3af; } +.lang-switch select { min-width: 120px; } + .status-bar { display: flex; width: 100%; @@ -184,9 +199,9 @@ button.ghost { border: 1px solid #b91c1c; } -.vote-controls { display: flex; gap: 10px; align-items: center; margin-top: auto; padding-top: 8px; } +.vote-controls { display: flex; gap: 10px; align-items: center; margin-top: auto; padding-top: 6px; } .score { font-weight: 700; min-width: 36px; text-align: center; } -.score-emoji { font-size: 18px; } +.score-emoji { font-size: 24px; text-align: center; } .results-grid .game-card { border-color: #2563eb44; }