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 { 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>
|
||||||
|
|||||||
@@ -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 0–10</h2>
|
<h2 data-i18n="section.vote">Vote 0–10</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
264
wwwroot/js/i18n.js
Normal 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 };
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user