[grade=A] fix: sync frontend i18n.js to 31 languages, fix race conditions and stale translation bug
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- Expand SUPPORTED_LANGS from 5 to all 31 languages matching backend
- Expand LANG_NAMES to include all 31 native language names
- Add RTL_LANGS set (ar, fa, ur) for multi-language RTL support
- Fix applyTranslations() to always write resolved value (clears stale
  translations when switching back to English)
- Fix loadTranslations() to clear translations on fetch failure/error
- Add monotonic _langRequestId token to prevent out-of-order async
  resolution race (A->B->A scenario)
- Wrap localStorage access in try/catch for privacy mode environments
- Validate stored language code against SUPPORTED_LANGS on init
- Always set document.documentElement.dir/lang on init (not just RTL)
- Add scrollable dropdown for 31 languages (max-height: 320px)
- Fix backend i18n route to use LANGUAGE_META instead of hardcoded 5-lang map
- Rebuild dist bundles

Codex grade: A (urn:ump:kicey7d7dnockmlk547cmm4qbdvygcj3g6waasrqv2yckbzem5bq)
1775/1775 tests pass
This commit is contained in:
Hermes
2026-08-13 13:22:31 -07:00
parent 3b3c4f8b8e
commit 56c976a935
4 changed files with 253 additions and 217 deletions
+5 -11
View File
@@ -10,18 +10,12 @@ module.exports = function() {
// GET /api/v1/i18n/languages — list supported languages // GET /api/v1/i18n/languages — list supported languages
router.get('/i18n/languages', (req, res) => { router.get('/i18n/languages', (req, res) => {
const meta = i18n.getAllLanguages();
ok(res, { ok(res, {
languages: i18n.getSupportedLanguages().map(code => ({ languages: i18n.getSupportedLanguages().map(code => {
code, const m = meta[code] || {};
name: { return { code, name: m.name || code, flag: m.flag || '🌐', rtl: !!m.rtl };
en: 'English', }),
es: 'Español',
fr: 'Français',
de: 'Deutsch',
ar: 'العربية',
}[code] || code,
rtl: code === 'ar',
})),
default: i18n.DEFAULT_LANGUAGE, default: i18n.DEFAULT_LANGUAGE,
}); });
}); });
+170 -170
View File
File diff suppressed because one or more lines are too long
+77 -35
View File
@@ -11,16 +11,45 @@
const STORAGE_KEY = 'dashcaddy-language'; const STORAGE_KEY = 'dashcaddy-language';
const DEFAULT_LANG = 'en'; const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'de', 'ar']; // Must match the 31 languages in language-selector.js and the backend i18n route.
const SUPPORTED_LANGS = [
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
];
const LANG_NAMES = { const LANG_NAMES = {
en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', ar: 'العربية', en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
}; };
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
let currentLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG; // Validate the stored language — if it's invalid (old/corrupt), fall back to default.
// Wrap in try/catch for environments where localStorage is disabled (private mode).
function _readValidLang() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
} catch (e) { /* localStorage unavailable */ }
return DEFAULT_LANG;
}
let currentLang = _readValidLang();
let translations = {}; let translations = {};
let loaded = false; let loaded = false;
// Monotonic token to guard against out-of-order async resolution.
// Each setLanguage / loadTranslations call captures the current value; if it
// changed by the time the fetch resolves, the result is discarded.
let _langRequestId = 0;
async function loadTranslations(lang) { async function loadTranslations(lang, reqId) {
// 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) { if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed translations = {}; // English is the default — no translation needed
loaded = true; loaded = true;
@@ -28,33 +57,48 @@
} }
try { try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`); const res = await fetch(`/api/v1/i18n/translations/${lang}`);
// Guard against out-of-order resolution: if another loadTranslations
// started after this one (or the user switched languages), discard.
if (reqId !== _langRequestId) return;
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (reqId !== _langRequestId) return; // double-check after second await
translations = data.translations || {}; translations = data.translations || {};
loaded = true; loaded = true;
} else {
// HTTP error — clear stale translations so we don't show the wrong language
translations = {};
loaded = false;
} }
} catch (e) { } catch (e) {
console.warn('[i18n] Failed to load translations for', lang, e); console.warn('[i18n] Failed to load translations for', lang, e);
if (reqId === _langRequestId) {
translations = {};
loaded = false;
}
} }
} }
function t(key) { function t(key) {
if (currentLang === DEFAULT_LANG) return key; if (currentLang === DEFAULT_LANG) return key;
// If translations didn't load, fall back to the English key
return translations[key] || key; return translations[key] || key;
} }
function setLanguage(lang) { function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return; if (!SUPPORTED_LANGS.includes(lang)) return;
currentLang = lang; currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang); try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
// RTL handling // RTL handling — always set dir/lang explicitly so switching back to LTR works.
const isRtl = lang === 'ar'; const isRtl = RTL_LANGS.has(lang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr'; document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = lang; document.documentElement.lang = lang;
loadTranslations(lang).then(() => { const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
applyTranslations(); loadTranslations(lang, reqId).then(() => {
// Only apply if this is still the latest request.
if (reqId === _langRequestId) applyTranslations();
}); });
} }
@@ -63,29 +107,23 @@
} }
function applyTranslations() { function applyTranslations() {
// Apply translations to elements with data-i18n attributes // Apply translations to elements with data-i18n attributes.
// Always write the resolved value — when switching back to English or when a
// key has no translation, this restores the original English text rather than
// leaving the previous language's translated text visible.
document.querySelectorAll('[data-i18n]').forEach(el => { document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n'); const key = el.getAttribute('data-i18n');
const translated = t(key); el.textContent = t(key);
if (translated && translated !== key) {
el.textContent = translated;
}
}); });
// Apply to placeholders // Apply to placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder'); const key = el.getAttribute('data-i18n-placeholder');
const translated = t(key); el.placeholder = t(key);
if (translated && translated !== key) {
el.placeholder = translated;
}
}); });
// Apply to titles // Apply to titles
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title'); const key = el.getAttribute('data-i18n-title');
const translated = t(key); el.title = t(key);
if (translated && translated !== key) {
el.title = translated;
}
}); });
} }
@@ -121,11 +159,11 @@
btn.className = 'lang-selector-btn'; btn.className = 'lang-selector-btn';
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;'; btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`; btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
btn.title = 'Language / Idioma / Langue / Sprache / اللغة'; btn.title = 'Select Language';
const dropdown = document.createElement('div'); const dropdown = document.createElement('div');
dropdown.id = 'dc-lang-dropdown'; dropdown.id = 'dc-lang-dropdown';
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 140px; overflow: hidden;'; dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 160px; max-height: 320px; overflow-y: auto;';
SUPPORTED_LANGS.forEach(lang => { SUPPORTED_LANGS.forEach(lang => {
const item = document.createElement('div'); const item = document.createElement('div');
@@ -181,21 +219,25 @@
// Initialize on page load // Initialize on page load
function init() { function init() {
// Set initial RTL if needed // Always set dir/lang explicitly — covers LTR reset and RTL setup.
if (currentLang === 'ar') { const isRtl = RTL_LANGS.has(currentLang);
document.documentElement.dir = 'rtl'; document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = 'ar'; document.documentElement.lang = currentLang;
function start() {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) {
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId) applyTranslations();
});
}
} }
// Create the language selector after DOM is ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', start);
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
});
} else { } else {
createLanguageSelector(); start();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-f0ebe6ce4f'; const CACHE = 'dashcaddy-shell-491e157c0e';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',