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 { 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 = `
<thead>
<tr>
<th>Rank</th>
<th>Game</th>
<th>Author</th>
<th>Votes</th>
<th>Avg</th>
<th>Total</th>
<th>Links</th>
<th>${t("results.rank")}</th>
<th>${t("results.game")}</th>
<th>${t("results.author")}</th>
<th>${t("results.votes")}</th>
<th>${t("results.avg")}</th>
<th>${t("results.total")}</th>
<th>${t("results.links")}</th>
</tr>
</thead>
<tbody></tbody>
@@ -261,8 +269,8 @@ function renderResults() {
<td>${r.average.toFixed(1)}</td>
<td>${r.total}</td>
<td>
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">Site ↗</a><br>` : ''}
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">YouTube</a>` : ''}
${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">${t("results.link.youtube")}</a>` : ''}
</td>
`;
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
? `<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>`;
card.innerHTML = `
${visual}
@@ -440,16 +471,16 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal
<div class="card-title-row">
<h3>${s.name}</h3>
<div class="title-meta">
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">Site ↗</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube</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">${t("card.youtube")}</a>` : ""}
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">Edit</button>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">Delete</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">${t("card.delete")}</button>` : ""}
</div>
</div>
${s.genre ? `<p class="muted">${s.genre}</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>
`;
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 = `
<div class="edit-panel">
<div class="edit-header">
<h3>Edit game</h3>
<button class="lightbox-close" aria-label="Close">×</button>
<h3>${t("modal.editTitle")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">×</button>
</div>
<div class="edit-body">
<form class="stack" id="edit-form">
<input name="name" required maxlength="100" placeholder="Game name *" value="${s.name ?? ""}" />
<input name="genre" maxlength="50" placeholder="Genre" value="${s.genre ?? ""}" />
<textarea name="description" maxlength="500" placeholder="Short description">${s.description ?? ""}</textarea>
<input name="name" required maxlength="100" placeholder="${t("form.placeholder.gameName")}" value="${s.name ?? ""}" />
<input name="genre" maxlength="50" placeholder="${t("form.placeholder.genre")}" value="${s.genre ?? ""}" />
<textarea name="description" maxlength="500" placeholder="${t("form.placeholder.description")}">${s.description ?? ""}</textarea>
<div class="stack">
<span class="label">Players</span>
<span class="label">${t("form.players")}</span>
<div class="stack horizontal">
<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 ?? ""}" />
</label>
<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 ?? ""}" />
</label>
</div>
</div>
<div class="stack horizontal">
<input name="screenshotUrl" maxlength="2048" placeholder="Screenshot URL" value="${s.screenshotUrl ?? ""}" />
<input name="youtubeUrl" maxlength="2048" placeholder="YouTube URL" value="${s.youtubeUrl ?? ""}" />
<input name="gameUrl" maxlength="2048" placeholder="Game website URL" value="${s.gameUrl ?? ""}" />
<input name="screenshotUrl" maxlength="2048" placeholder="${t("form.placeholder.screenshot")}" value="${s.screenshotUrl ?? ""}" />
<input name="youtubeUrl" maxlength="2048" placeholder="${t("form.placeholder.youtube")}" value="${s.youtubeUrl ?? ""}" />
<input name="gameUrl" maxlength="2048" placeholder="${t("form.placeholder.gameUrl")}" value="${s.gameUrl ?? ""}" />
</div>
<div class="stack horizontal">
<button type="submit">Save changes</button>
<button type="button" class="ghost" id="edit-cancel">Cancel</button>
<button type="submit">${t("modal.save")}</button>
<button type="button" class="ghost" id="edit-cancel">${t("modal.cancel")}</button>
</div>
</form>
</div>
@@ -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 = `
<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}" />
<p>${title || ""}</p>
</div>