Add i18n language selector to dashboard frontend
- New i18n.js module: window.DCI18n.t(key), language dropdown selector - Supports 5 languages (en, es, fr, de, ar) with RTL for Arabic - Translations loaded from /api/v1/i18n/translations/:lang - Language preference persisted in localStorage - Added to features.js bundle
This commit is contained in:
@@ -77,6 +77,7 @@ const bundles = {
|
||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||
// defined in globals.js (already in core.js).
|
||||
JS('share-modal.js'),
|
||||
JS('i18n.js'),
|
||||
],
|
||||
'onboarding.js': [
|
||||
JS('driver.min.js'),
|
||||
|
||||
Vendored
+187
-187
File diff suppressed because one or more lines are too long
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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';
|
||||
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'de', 'ar'];
|
||||
const LANG_NAMES = {
|
||||
en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', ar: 'العربية',
|
||||
};
|
||||
|
||||
let currentLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
|
||||
let translations = {};
|
||||
let loaded = false;
|
||||
|
||||
async function loadTranslations(lang) {
|
||||
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}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
translations = data.translations || {};
|
||||
loaded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Failed to load translations for', lang, e);
|
||||
}
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
|
||||
// RTL handling
|
||||
const isRtl = lang === 'ar';
|
||||
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
loadTranslations(lang).then(() => {
|
||||
applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function applyTranslations() {
|
||||
// Apply translations to elements with data-i18n attributes
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.placeholder = translated;
|
||||
}
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
const translated = t(key);
|
||||
if (translated && translated !== key) {
|
||||
el.title = translated;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = 'Language / Idioma / Langue / Sprache / اللغة';
|
||||
|
||||
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: 140px; overflow: hidden;';
|
||||
|
||||
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() {
|
||||
// Set initial RTL if needed
|
||||
if (currentLang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = 'ar';
|
||||
}
|
||||
|
||||
// Create the language selector after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
|
||||
});
|
||||
} else {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
})();
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-d655ab9676';
|
||||
const CACHE = 'dashcaddy-shell-c775f8444e';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user