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.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 = `
@@ -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 = `
-
+
${title || ""}
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 @@
+
+
+
+
@@ -64,90 +71,90 @@
-
Your suggestions
+
Your suggestions
-
All Suggestions
+
All Suggestions
-
+
-
+
-
-
+
+
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; }