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.
261 lines
10 KiB
JavaScript
261 lines
10 KiB
JavaScript
/**
|
|
* DC-077: i18n Frontend — Language selector and translation system
|
|
*
|
|
* Provides window.DCI18n.t(key) for the dashboard frontend.
|
|
* Loads translations from /api/v1/i18n/translations/:lang
|
|
* Language preference stored in localStorage.
|
|
* Handles RTL for Arabic.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const STORAGE_KEY = 'dashcaddy-language';
|
|
const DEFAULT_LANG = 'en';
|
|
// 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 = {
|
|
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']);
|
|
|
|
// 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 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, 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;
|
|
try {
|
|
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) {
|
|
const data = await res.json();
|
|
if (reqId !== _langRequestId) return; // double-check after second await
|
|
translations = data.translations || {};
|
|
loaded = true;
|
|
} else {
|
|
// HTTP error — clear stale translations so we don't show the wrong language
|
|
translations = {};
|
|
loaded = false;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[i18n] Failed to load translations for', lang, e);
|
|
if (reqId === _langRequestId) {
|
|
translations = {};
|
|
loaded = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function t(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;
|
|
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
|
|
|
|
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
|
|
const isRtl = RTL_LANGS.has(lang);
|
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
|
document.documentElement.lang = lang;
|
|
|
|
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
|
loadTranslations(lang, reqId).then(() => {
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
function getLanguage() {
|
|
return currentLang;
|
|
}
|
|
|
|
function applyTranslations() {
|
|
// 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 => {
|
|
const key = el.getAttribute('data-i18n');
|
|
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');
|
|
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 => {
|
|
const key = el.getAttribute('data-i18n-title');
|
|
el.title = t(key);
|
|
});
|
|
}
|
|
|
|
function createLanguageSelector() {
|
|
// Find the top bar area to insert the selector
|
|
// Look for the auth-settings area or the header actions
|
|
const targetContainer = document.querySelector('.header-actions') ||
|
|
document.querySelector('#auth-settings-btn')?.parentElement ||
|
|
document.querySelector('.top-bar-actions');
|
|
|
|
if (!targetContainer) {
|
|
// If we can't find a target, try to add it near the settings button
|
|
const settingsBtn = document.getElementById('auth-settings-btn');
|
|
if (settingsBtn && settingsBtn.parentElement) {
|
|
return createDropdown(settingsBtn.parentElement);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return createDropdown(targetContainer);
|
|
}
|
|
|
|
function createDropdown(container) {
|
|
// Check if selector already exists
|
|
if (document.getElementById('dc-lang-selector')) return;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.id = 'dc-lang-selector';
|
|
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.id = 'dc-lang-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.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
|
|
btn.title = 'Select Language';
|
|
|
|
const dropdown = document.createElement('div');
|
|
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: 160px; max-height: 320px; overflow-y: auto;';
|
|
|
|
SUPPORTED_LANGS.forEach(lang => {
|
|
const item = document.createElement('div');
|
|
item.className = 'lang-option';
|
|
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
|
|
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
|
|
item.onmouseleave = () => item.style.background = 'transparent';
|
|
|
|
const flag = document.createElement('span');
|
|
flag.textContent = lang === currentLang ? '✓' : '';
|
|
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
|
|
|
|
const name = document.createElement('span');
|
|
name.textContent = LANG_NAMES[lang];
|
|
|
|
item.appendChild(flag);
|
|
item.appendChild(name);
|
|
item.onclick = () => {
|
|
setLanguage(lang);
|
|
dropdown.style.display = 'none';
|
|
// Update button text
|
|
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
|
|
// Update checkmarks
|
|
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
|
|
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
|
|
});
|
|
// Show notification
|
|
if (window.showNotification) {
|
|
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
|
|
}
|
|
};
|
|
dropdown.appendChild(item);
|
|
});
|
|
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
|
};
|
|
|
|
// Close on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!wrapper.contains(e.target)) {
|
|
dropdown.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
wrapper.appendChild(btn);
|
|
wrapper.appendChild(dropdown);
|
|
container.insertBefore(wrapper, container.firstChild);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
// Initialize on page load
|
|
function init() {
|
|
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
|
|
const isRtl = RTL_LANGS.has(currentLang);
|
|
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
|
document.documentElement.lang = currentLang;
|
|
|
|
function start() {
|
|
createLanguageSelector();
|
|
const reqId = ++_langRequestId;
|
|
loadTranslations(currentLang, reqId).then(() => {
|
|
if (reqId === _langRequestId && loaded) applyTranslations();
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
} else {
|
|
start();
|
|
}
|
|
}
|
|
|
|
// Expose globally
|
|
window.DCI18n = { t, setLanguage, getLanguage, isLoaded, applyTranslations, loadTranslations };
|
|
|
|
// Auto-init
|
|
init();
|
|
})();
|