[glm-grade=B+] feat(i18n): complete card/filter/action translation keys for 31 languages

Full language display names + RTL set (ar/fa/ur) on /i18n/languages;
card.internet/auth/tailscale/dashca, status pills, filter bar and
batch-operation strings added to every language dictionary. Frontend:
English now loads the server dictionary too (keys are semantic ids,
not fallback copy), failed loads keep existing DOM text instead of
exposing raw keys, isLoaded() gate for pre-load renders. Rebuilt
status/dist. Tests: i18n-cards 9/9, full suite 1837/1837.
This commit is contained in:
Krystie
2026-08-15 00:01:17 -07:00
parent bd40fb1c17
commit ef685e515e
11 changed files with 495 additions and 167 deletions
+19 -3
View File
@@ -1,6 +1,12 @@
// ========== GRID & STATUS HELPERS ==========
(function () {
function statusLabel(up) {
const i18n = window.DCI18n;
if (!i18n || !i18n.isLoaded()) return up ? 'ON' : 'OFF';
return i18n.t(up ? 'card.status.on' : 'card.status.off');
}
/* Enhanced status helpers with response time tracking */
function setQuick(id, up, responseTime = null) {
const dot = document.getElementById(id + '-dot');
@@ -14,7 +20,7 @@
}
if (pill) {
pill.textContent = up ? 'ON' : 'OFF';
pill.textContent = statusLabel(up);
pill.classList.toggle('on', up);
pill.classList.toggle('off', !up);
}
@@ -172,7 +178,10 @@
row.appendChild(el('span', 'spacer'));
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
const pill = el('span', 'badge off', 'OFF');
pill.id = 'badge-' + s.id;
pill.setAttribute('data-i18n-live-status', 'binary');
row.appendChild(pill);
// Update available badge (hidden by default, shown when update detected)
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
@@ -301,6 +310,7 @@
}
const btn = el('button', null, 'Open');
btn.setAttribute('data-i18n', 'action.open');
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
btnRow.appendChild(btn);
card.appendChild(btnRow);
@@ -309,6 +319,12 @@
root.appendChild(card);
}
// Cards can be created after the initial language pass. Translate their
// initial live state and controls immediately instead of waiting for poll.
if (window.DCI18n && window.DCI18n.isLoaded()) {
window.DCI18n.applyTranslations();
}
requestAnimationFrame(() => {
root.querySelectorAll('.card').forEach(card => card.classList.add('loaded'));
});
@@ -332,7 +348,7 @@
}
if (pill) {
pill.textContent = up ? 'ON' : 'OFF';
pill.textContent = statusLabel(up);
pill.classList.toggle('on', up);
pill.classList.toggle('off', !up);
}
+7 -4
View File
@@ -147,18 +147,21 @@ function renderDnsCards() {
`<span id="${safeId}-dot" class="dot bad at-bl"></span>`
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
+ `<span class="name">${label}</span><span class="spacer"></span>`
+ `<span id="${safeId}-pill" class="badge off">OFF</span></div>`
+ `<span id="${safeId}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div>`
+ `<div class="response-row"><span id="${safeId}-time" class="response-time">--</span></div>`
+ `<div class="health-row" id="health-${safeId}"><span id="uptime-${safeId}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${safeId}" style="width: 0%"></div></div></div>`
+ `<div class="btn-row">`
+ `<button id="${safeId}-restart" class="restart-btn">Restart</button>`
+ `<button id="${safeId}-restart" class="restart-btn" data-i18n="action.restart">Restart</button>`
+ `<button id="${safeId}-update" class="update-btn" title="Update DNS server">⬆️</button>`
+ `<button id="${safeId}-open">Open</button>`
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>`
+ `<button id="${safeId}-open" data-i18n="action.open">Open</button>`
+ `<button id="${safeId}-logs" class="logs-btn" data-i18n="action.logs">Logs</button>`
+ `<button id="${safeId}-settings" class="settings-btn">⚙️</button>`
+ `</div>`;
topRow.insertBefore(card, firstChild);
});
if (window.DCI18n && window.DCI18n.isLoaded()) {
window.DCI18n.applyTranslations();
}
}
window.renderDnsCards = renderDnsCards;
+29 -18
View File
@@ -50,11 +50,6 @@
// reqId is the monotonic token incremented by the caller (setLanguage/init).
// If not provided (direct API call), allocate one for backward compatibility.
if (reqId === undefined) reqId = ++_langRequestId;
if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed
loaded = true;
return;
}
try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
// Guard against out-of-order resolution: if another loadTranslations
@@ -80,11 +75,15 @@
}
function t(key) {
if (currentLang === DEFAULT_LANG) return key;
// If translations didn't load, fall back to the English key
// Translation keys are semantic identifiers, not English fallback copy.
// Callers that render before loading should retain their existing DOM text.
return translations[key] || key;
}
function isLoaded() {
return loaded;
}
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return;
currentLang = lang;
@@ -97,8 +96,9 @@
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
loadTranslations(lang, reqId).then(() => {
// Only apply if this is still the latest request.
if (reqId === _langRequestId) applyTranslations();
// Only apply a successfully loaded dictionary. On HTTP/network failure,
// retain the existing readable DOM copy instead of exposing semantic keys.
if (reqId === _langRequestId && loaded) applyTranslations();
});
}
@@ -113,12 +113,25 @@
// leaving the previous language's translated text visible.
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
const prefix = el.getAttribute('data-i18n-prefix') || '';
el.textContent = prefix + t(key);
});
// Apply to placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
const prefix = el.getAttribute('data-i18n-prefix') || '';
el.placeholder = prefix + t(key);
});
// Live status pills derive the translation key from current runtime state.
// A language switch must never reset an online card to its initial OFF copy.
document.querySelectorAll('[data-i18n-live-status]').forEach(el => {
const card = el.closest('[data-status]');
const isOn = card && card.getAttribute('data-status') === 'on';
const mode = el.getAttribute('data-i18n-live-status');
const key = mode === 'yesno'
? (isOn ? 'card.status.yes' : 'card.status.no')
: (isOn ? 'card.status.on' : 'card.status.off');
el.textContent = t(key);
});
// Apply to titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
@@ -226,12 +239,10 @@
function start() {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) {
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId) applyTranslations();
});
}
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId && loaded) applyTranslations();
});
}
if (document.readyState === 'loading') {
@@ -242,7 +253,7 @@
}
// Expose globally
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
window.DCI18n = { t, setLanguage, getLanguage, isLoaded, applyTranslations, loadTranslations };
// Auto-init
init();
+8 -3
View File
@@ -169,6 +169,11 @@
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled'
};
function translated(key, fallback) {
const i18n = window.DCI18n;
return i18n && i18n.isLoaded() ? i18n.t(key) : fallback;
}
function updateAuthCard(active, duration) {
const card = document.getElementById('auth-card');
const pill = document.getElementById('auth-pill');
@@ -179,15 +184,15 @@
if (active) {
card.setAttribute('data-status', 'on');
pill.className = 'badge on';
pill.textContent = 'YES';
pill.textContent = translated('card.status.yes', 'YES');
dot.className = 'dot ok at-bl';
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
} else {
card.setAttribute('data-status', 'off');
pill.className = 'badge off';
pill.textContent = 'NO';
pill.textContent = translated('card.status.no', 'NO');
dot.className = 'dot bad at-bl';
statusText.textContent = 'Not configured';
statusText.textContent = translated('card.auth.not_configured', 'Not configured');
}
}