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

View File

@@ -1,4 +1,7 @@
import { api, adminApi } from "./js/api.js"; import { api, adminApi } from "./js/api.js";
import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js";
initI18n();
const state = { const state = {
isAuthenticated: false, isAuthenticated: false,
@@ -76,7 +79,7 @@ function handleAuthError(err) {
setAuthUI(false); setAuthUI(false);
return true; return true;
} }
toast(err?.message || "Unexpected error", true); toast(err?.message || t("toast.unexpected"), true);
return false; return false;
} }
@@ -128,7 +131,8 @@ async function loadResults() {
} }
function renderPhasePill() { 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")); document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden"));
const viewMap = { const viewMap = {
Suggest: "suggest-view", Suggest: "suggest-view",
@@ -146,14 +150,18 @@ function renderPhasePill() {
function renderCounts() { function renderCounts() {
if (!state.counts) return; 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() { function renderWelcome() {
const el = $("welcome-text"); const el = $("welcome-text");
if (!el) return; if (!el) return;
const name = state.me?.displayName?.trim() || state.me?.username || "Player"; const name = state.me?.displayName?.trim() || state.me?.username || t("auth.defaultName");
el.textContent = `Welcome, ${name}!`; el.textContent = t("auth.welcome", { name });
} }
function renderMySuggestions() { function renderMySuggestions() {
@@ -202,7 +210,7 @@ function renderVotes() {
const score = Number(e.target.value); const score = Number(e.target.value);
try { try {
await api.vote(suggestionId, score); await api.vote(suggestionId, score);
toast("Saved vote"); toast(t("vote.saved"));
await loadVoteData(); await loadVoteData();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
@@ -233,13 +241,13 @@ function renderResults() {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th>Rank</th> <th>${t("results.rank")}</th>
<th>Game</th> <th>${t("results.game")}</th>
<th>Author</th> <th>${t("results.author")}</th>
<th>Votes</th> <th>${t("results.votes")}</th>
<th>Avg</th> <th>${t("results.avg")}</th>
<th>Total</th> <th>${t("results.total")}</th>
<th>Links</th> <th>${t("results.links")}</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -261,8 +269,8 @@ function renderResults() {
<td>${r.average.toFixed(1)}</td> <td>${r.average.toFixed(1)}</td>
<td>${r.total}</td> <td>${r.total}</td>
<td> <td>
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">Site ↗</a><br>` : ''} ${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''}
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">YouTube</a>` : ''} ${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''}
</td> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
@@ -279,22 +287,45 @@ function setupHandlers() {
}); });
setAuthMode(state.authMode); 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"); const loginForm = $("login-form");
if (loginForm) { if (loginForm) {
loginForm.addEventListener("submit", async (e) => { loginForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const username = $("login-username").value.trim(); const username = $("login-username").value.trim();
const password = $("login-password").value; 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 { try {
await api.login({ username, password }); await api.login({ username, password });
setSavedUsername(username); setSavedUsername(username);
state.isAuthenticated = true; state.isAuthenticated = true;
setAuthUI(true); setAuthUI(true);
await refreshPhaseData(); await refreshPhaseData();
toast("Logged in"); toast(t("toast.loggedIn"));
} catch (err) { } 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; if (handleAuthError(err)) return;
} }
}); });
@@ -308,14 +339,14 @@ function setupHandlers() {
const password = $("register-password").value; const password = $("register-password").value;
const displayName = $("register-displayName").value.trim(); const displayName = $("register-displayName").value.trim();
const adminKey = $("register-adminkey").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 { try {
await api.register({ username, password, displayName, adminKey }); await api.register({ username, password, displayName, adminKey });
setSavedUsername(username); setSavedUsername(username);
state.isAuthenticated = true; state.isAuthenticated = true;
setAuthUI(true); setAuthUI(true);
await refreshPhaseData(); await refreshPhaseData();
toast("Registered"); toast(t("toast.registered"));
} catch (err) { } catch (err) {
if (handleAuthError(err)) return; if (handleAuthError(err)) return;
toast(err.message, true); toast(err.message, true);
@@ -327,14 +358,14 @@ function setupHandlers() {
e.preventDefault(); e.preventDefault();
const form = e.target; const form = e.target;
const data = normalizeSuggestionForm(new FormData(form)); 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)) { 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 { try {
await api.createSuggestion(data); await api.createSuggestion(data);
form.reset(); form.reset();
toast("Suggestion added"); toast(t("toast.suggestionAdded"));
await loadSuggestData(); await loadSuggestData();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
@@ -345,7 +376,7 @@ function setupHandlers() {
const phase = $("phase-select").value; const phase = $("phase-select").value;
try { try {
await adminApi.setPhase(phase); await adminApi.setPhase(phase);
toast("Phase updated"); toast(t("admin.phaseUpdated"));
state.prevPhase = state.phase; state.prevPhase = state.phase;
state.phase = phase; state.phase = phase;
state.votesRendered = false; state.votesRendered = false;
@@ -363,8 +394,8 @@ function setupHandlers() {
}); });
phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; }); phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; });
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete")); $("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete")); $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
const logoutBtn = $("logout"); const logoutBtn = $("logout");
if (logoutBtn) { if (logoutBtn) {
@@ -432,7 +463,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal
card.className = "game-card"; card.className = "game-card";
const hasImage = !!s.screenshotUrl; const hasImage = !!s.screenshotUrl;
const visual = hasImage const visual = hasImage
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="Open screenshot" style="background-image:url('${s.screenshotUrl}')"></button>` ? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
: `<div class="card-visual"></div>`; : `<div class="card-visual"></div>`;
card.innerHTML = ` card.innerHTML = `
${visual} ${visual}
@@ -440,16 +471,16 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal
<div class="card-title-row"> <div class="card-title-row">
<h3>${s.name}</h3> <h3>${s.name}</h3>
<div class="title-meta"> <div class="title-meta">
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">Site ↗</a>` : ""} ${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube</a>` : ""} ${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""} ${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">Edit</button>` : ""} ${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">${t("card.edit")}</button>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</button>` : ""} ${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">${t("card.delete")}</button>` : ""}
</div> </div>
</div> </div>
${s.genre ? `<p class="muted">${s.genre}</p>` : ""} ${s.genre ? `<p class="muted">${s.genre}</p>` : ""}
${s.description ? `<p>${s.description}</p>` : ""} ${s.description ? `<p>${s.description}</p>` : ""}
${(s.minPlayers || s.maxPlayers) ? `<p class="muted">Players: ${s.minPlayers ?? "?"}${s.maxPlayers ?? "?"}</p>` : ""} ${(s.minPlayers || s.maxPlayers) ? `<p class="muted">${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}</p>` : ""}
</div> </div>
`; `;
if (hasImage) { if (hasImage) {
@@ -465,7 +496,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal
del.addEventListener("click", async () => { del.addEventListener("click", async () => {
try { try {
await api.deleteSuggestion(s.id); await api.deleteSuggestion(s.id);
toast("Suggestion deleted"); toast(t("toast.suggestionDeleted"));
await loadSuggestData(); await loadSuggestData();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
@@ -481,35 +512,35 @@ function openEditModal(s) {
overlay.innerHTML = ` overlay.innerHTML = `
<div class="edit-panel"> <div class="edit-panel">
<div class="edit-header"> <div class="edit-header">
<h3>Edit game</h3> <h3>${t("modal.editTitle")}</h3>
<button class="lightbox-close" aria-label="Close">×</button> <button class="lightbox-close" aria-label="${t("modal.close")}">×</button>
</div> </div>
<div class="edit-body"> <div class="edit-body">
<form class="stack" id="edit-form"> <form class="stack" id="edit-form">
<input name="name" required maxlength="100" placeholder="Game name *" value="${s.name ?? ""}" /> <input name="name" required maxlength="100" placeholder="${t("form.placeholder.gameName")}" value="${s.name ?? ""}" />
<input name="genre" maxlength="50" placeholder="Genre" value="${s.genre ?? ""}" /> <input name="genre" maxlength="50" placeholder="${t("form.placeholder.genre")}" value="${s.genre ?? ""}" />
<textarea name="description" maxlength="500" placeholder="Short description">${s.description ?? ""}</textarea> <textarea name="description" maxlength="500" placeholder="${t("form.placeholder.description")}">${s.description ?? ""}</textarea>
<div class="stack"> <div class="stack">
<span class="label">Players</span> <span class="label">${t("form.players")}</span>
<div class="stack horizontal"> <div class="stack horizontal">
<label class="stack"> <label class="stack">
<span class="label">Min</span> <span class="label">${t("form.min")}</span>
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.minPlayers ?? ""}" /> <input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.minPlayers ?? ""}" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Max</span> <span class="label">${t("form.max")}</span>
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.maxPlayers ?? ""}" /> <input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.maxPlayers ?? ""}" />
</label> </label>
</div> </div>
</div> </div>
<div class="stack horizontal"> <div class="stack horizontal">
<input name="screenshotUrl" maxlength="2048" placeholder="Screenshot URL" value="${s.screenshotUrl ?? ""}" /> <input name="screenshotUrl" maxlength="2048" placeholder="${t("form.placeholder.screenshot")}" value="${s.screenshotUrl ?? ""}" />
<input name="youtubeUrl" maxlength="2048" placeholder="YouTube URL" value="${s.youtubeUrl ?? ""}" /> <input name="youtubeUrl" maxlength="2048" placeholder="${t("form.placeholder.youtube")}" value="${s.youtubeUrl ?? ""}" />
<input name="gameUrl" maxlength="2048" placeholder="Game website URL" value="${s.gameUrl ?? ""}" /> <input name="gameUrl" maxlength="2048" placeholder="${t("form.placeholder.gameUrl")}" value="${s.gameUrl ?? ""}" />
</div> </div>
<div class="stack horizontal"> <div class="stack horizontal">
<button type="submit">Save changes</button> <button type="submit">${t("modal.save")}</button>
<button type="button" class="ghost" id="edit-cancel">Cancel</button> <button type="button" class="ghost" id="edit-cancel">${t("modal.cancel")}</button>
</div> </div>
</form> </form>
</div> </div>
@@ -529,12 +560,12 @@ function openEditModal(s) {
e.preventDefault(); e.preventDefault();
const data = normalizeSuggestionForm(new FormData(form)); const data = normalizeSuggestionForm(new FormData(form));
if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { 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 { try {
await api.updateSuggestion(s.id, data); await api.updateSuggestion(s.id, data);
toast("Saved changes"); toast(t("toast.savedChanges"));
close(); close();
await refreshPhaseData(); await refreshPhaseData();
} catch (err) { } catch (err) {
@@ -551,7 +582,7 @@ function openLightbox(url, title) {
overlay.className = "lightbox"; overlay.className = "lightbox";
overlay.innerHTML = ` overlay.innerHTML = `
<div class="lightbox-content"> <div class="lightbox-content">
<button class="lightbox-close" aria-label="Close">✕</button> <button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${title}" /> <img src="${url}" alt="${title}" />
<p>${title || ""}</p> <p>${title || ""}</p>
</div> </div>

View File

@@ -9,52 +9,59 @@
<meta name="app-base" content=""> <meta name="app-base" content="">
</head> </head>
<body class="page"> <body class="page">
<div class="lang-switch">
<label for="language-select" data-i18n="lang.label">Language</label>
<select id="language-select">
<option value="en" data-i18n="lang.en">English</option>
<option value="de" data-i18n="lang.de">Deutsch</option>
</select>
</div>
<section class="card hidden" id="auth-card"> <section class="card hidden" id="auth-card">
<div class="stack horizontal"> <div class="stack horizontal">
<button class="ghost active" data-auth-tab="login" type="button">Log in</button> <button class="ghost active" data-auth-tab="login" type="button" data-i18n="auth.loginTab">Log in</button>
<button class="ghost" data-auth-tab="register" type="button">Register</button> <button class="ghost" data-auth-tab="register" type="button" data-i18n="auth.registerTab">Register</button>
</div> </div>
<form id="login-form" class="stack auth-form" data-mode="login"> <form id="login-form" class="stack auth-form" data-mode="login">
<label class="stack"> <label class="stack">
<span class="label">Username</span> <span class="label" data-i18n="auth.username">Username</span>
<input id="login-username" name="username" maxlength="64" autocomplete="username" required /> <input id="login-username" name="username" maxlength="64" autocomplete="username" required />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Password</span> <span class="label" data-i18n="auth.password">Password</span>
<input id="login-password" name="password" type="password" autocomplete="current-password" required /> <input id="login-password" name="password" type="password" autocomplete="current-password" required />
</label> </label>
<button type="submit">Log in</button> <button type="submit" data-i18n="auth.loginSubmit">Log in</button>
</form> </form>
<form id="register-form" class="stack auth-form hidden" data-mode="register"> <form id="register-form" class="stack auth-form hidden" data-mode="register">
<label class="stack"> <label class="stack">
<span class="label">Username</span> <span class="label" data-i18n="auth.username">Username</span>
<input id="register-username" name="username" maxlength="64" autocomplete="username" required /> <input id="register-username" name="username" maxlength="64" autocomplete="username" required />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Password</span> <span class="label" data-i18n="auth.password">Password</span>
<input id="register-password" name="password" type="password" autocomplete="new-password" required /> <input id="register-password" name="password" type="password" autocomplete="new-password" required />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Display name (shows to group)</span> <span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
<input id="register-displayName" name="displayName" maxlength="64" required /> <input id="register-displayName" name="displayName" maxlength="64" required />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Admin key (optional)</span> <span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" /> <input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
</label> </label>
<button type="submit">Create account</button> <button type="submit" data-i18n="auth.registerSubmit">Create account</button>
</form> </form>
</section> </section>
<section> <section>
<div class="status-bar"> <div class="status-bar">
<div class="status-left"> <div class="status-left">
<span id="welcome-text">Welcome!</span> <span id="welcome-text" data-i18n="auth.welcome">Welcome!</span>
<a id="logout" href="#" class="link inline-link">Logout</a> <a id="logout" href="#" class="link inline-link" data-i18n="auth.logout">Logout</a>
</div> </div>
<div class="status-right"> <div class="status-right">
<span class="status-dot"></span> <span class="status-dot"></span>
<span id="phase-pill">Loading…</span> <span id="phase-pill" data-i18n="phase.loading">Loading…</span>
<span class="counts" id="counts"></span> <span class="counts" id="counts"></span>
</div> </div>
</div> </div>
@@ -64,90 +71,90 @@
<section id="actions-card"> <section id="actions-card">
<div id="suggest-view" class="phase-view hidden"> <div id="suggest-view" class="phase-view hidden">
<div class="card"> <div class="card">
<h2>Suggest (up to 5)</h2> <h2 data-i18n="suggest.title">Suggest (up to 5)</h2>
<p class="hint">Only you can see your suggestions until Reveal.</p> <p class="hint" data-i18n="suggest.hint">Only you can see your suggestions until Reveal.</p>
<form id="suggest-form" class="stack"> <form id="suggest-form" class="stack">
<label class="stack"> <label class="stack">
<span class="label">Game name *</span> <span class="label" data-i18n="form.gameName">Game name *</span>
<input name="name" required maxlength="100" /> <input name="name" required maxlength="100" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Genre</span> <span class="label" data-i18n="form.genre">Genre</span>
<input name="genre" maxlength="50" /> <input name="genre" maxlength="50" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Description</span> <span class="label" data-i18n="form.description">Description</span>
<textarea name="description" maxlength="500"></textarea> <textarea name="description" maxlength="500"></textarea>
</label> </label>
<div class="stack"> <div class="stack">
<span class="label">Players</span> <span class="label" data-i18n="form.players">Players</span>
<div class="stack horizontal"> <div class="stack horizontal">
<label class="stack"> <label class="stack">
<span class="label">Min</span> <span class="label" data-i18n="form.min">Min</span>
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" /> <input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Max</span> <span class="label" data-i18n="form.max">Max</span>
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" /> <input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
</label> </label>
</div> </div>
</div> </div>
<label class="stack"> <label class="stack">
<span class="label">Screenshot URL</span> <span class="label" data-i18n="form.screenshot">Screenshot URL</span>
<input name="screenshotUrl" maxlength="2048" /> <input name="screenshotUrl" maxlength="2048" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">YouTube URL</span> <span class="label" data-i18n="form.youtube">YouTube URL</span>
<input name="youtubeUrl" maxlength="2048" /> <input name="youtubeUrl" maxlength="2048" />
</label> </label>
<label class="stack"> <label class="stack">
<span class="label">Game website URL</span> <span class="label" data-i18n="form.gameUrl">Game website URL</span>
<input name="gameUrl" maxlength="2048" /> <input name="gameUrl" maxlength="2048" />
</label> </label>
<button type="submit">Submit</button> <button type="submit" data-i18n="form.submit">Submit</button>
</form> </form>
</div> </div>
<div class="card subcard"> <div class="card subcard">
<h3>Your suggestions</h3> <h3 data-i18n="section.mySuggestions">Your suggestions</h3>
<div id="my-suggestions" class="card-grid"></div> <div id="my-suggestions" class="card-grid"></div>
</div> </div>
</div> </div>
<div id="reveal-view" class="phase-view hidden"> <div id="reveal-view" class="phase-view hidden">
<h2>All Suggestions</h2> <h2 data-i18n="section.allSuggestions">All Suggestions</h2>
<div id="all-suggestions" class="card-grid"></div> <div id="all-suggestions" class="card-grid"></div>
</div> </div>
<div id="vote-view" class="phase-view hidden"> <div id="vote-view" class="phase-view hidden">
<h2>Vote 010</h2> <h2 data-i18n="section.vote">Vote 010</h2>
<div id="vote-list" class="card-grid"></div> <div id="vote-list" class="card-grid"></div>
</div> </div>
<div id="results-view" class="phase-view hidden"> <div id="results-view" class="phase-view hidden">
<h2>Results</h2> <h2 data-i18n="section.results">Results</h2>
<div id="results-list" class="card-grid results-grid"></div> <div id="results-list" class="card-grid results-grid"></div>
</div> </div>
</section> </section>
</main> </main>
<button id="admin-toggle" class="admin-toggle" title="Admin tools">•••</button> <button id="admin-toggle" class="admin-toggle" title="Admin tools" data-i18n="admin.tools" data-i18n-attr="title">•••</button>
<section class="card admin-panel hidden" id="admin-card"> <section class="card admin-panel hidden" id="admin-card">
<div class="panel-header"> <div class="panel-header">
<h3>Admin</h3> <h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button> <button id="admin-close" class="ghost"></button>
</div> </div>
<div class="stack horizontal"> <div class="stack horizontal">
<select id="phase-select"> <select id="phase-select">
<option>Suggest</option> <option value="Suggest" data-i18n="phase.suggest">Suggest</option>
<option>Reveal</option> <option value="Reveal" data-i18n="phase.reveal">Reveal</option>
<option>Vote</option> <option value="Vote" data-i18n="phase.vote">Vote</option>
<option>Results</option> <option value="Results" data-i18n="phase.results">Results</option>
</select> </select>
<button id="set-phase">Set phase</button> <button id="set-phase" data-i18n="admin.setPhase">Set phase</button>
</div> </div>
<div class="stack horizontal"> <div class="stack horizontal">
<button id="reset" class="danger">Reset (keep players)</button> <button id="reset" class="danger" data-i18n="admin.reset">Reset (keep players)</button>
<button id="factory-reset" class="danger">Factory reset</button> <button id="factory-reset" class="danger" data-i18n="admin.factoryReset">Factory reset</button>
</div> </div>
</section> </section>

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 };

View File

@@ -13,6 +13,21 @@
gap: 16px; 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 { .status-bar {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -184,9 +199,9 @@ button.ghost {
border: 1px solid #b91c1c; 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 { 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; } .results-grid .game-card { border-color: #2563eb44; }