[grade=A] fix: sync frontend i18n.js to 31 languages, fix race conditions and stale translation bug
- 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:
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+170
-170
File diff suppressed because one or more lines are too long
+77
-35
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-f0ebe6ce4f';
|
const CACHE = 'dashcaddy-shell-491e157c0e';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user