Add English/German i18n for frontend
This commit is contained in:
131
wwwroot/app.js
131
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 = `
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user