/** * DC-077: i18n Language Selector * * Compact dropdown in the navbar (next to the theme toggle) that lets users switch * the dashboard language between en / es / zh / ar / de. * * - Shows current language with flag emoji * - Persists selection to localStorage('dashcaddy-language') * - Sends selection to backend via POST /api/v1/config with { language: 'xx' } * - Reloads the page on change so the new language takes effect * * Depends on: secureFetch() / postJSON() from globals.js (bundled in /dist/core.js). */ (function () { 'use strict'; const STORAGE_KEY = 'dashcaddy-language'; const CONFIG_ENDPOINT = '/api/v1/config'; const LANGUAGES = [ { code: 'en', flag: '🇺🇸', label: 'English' }, { code: 'es', flag: '🇪🇸', label: 'Español' }, { code: 'zh', flag: '🇨🇳', label: '中文' }, { code: 'ar', flag: '🇸🇦', label: 'العربية' }, { code: 'de', flag: '🇩🇪', label: 'Deutsch' }, ]; const SUPPORTED = LANGUAGES.map(l => l.code); function getCurrentLanguage() { const stored = localStorage.getItem(STORAGE_KEY); if (stored && SUPPORTED.includes(stored)) return stored; return 'en'; } function langMeta(code) { return LANGUAGES.find(l => l.code === code) || LANGUAGES[0]; } // ===== Inject styles once ===== function injectStyles() { if (document.getElementById('dc-lang-selector-styles')) return; const style = document.createElement('style'); style.id = 'dc-lang-selector-styles'; style.textContent = ` .dc-lang-wrap { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; } .dc-lang-btn { display: flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; background: var(--card-base); color: var(--accent); border: 1px solid var(--border); font-family: inherit; } .dc-lang-btn:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } .dc-lang-btn .dc-lang-flag { font-size: 1rem; line-height: 1; } .dc-lang-btn .dc-lang-caret { font-size: 0.6rem; opacity: 0.7; margin-left: 2px; } .dc-lang-menu { position: absolute; top: calc(100% + 6px); right: 0; min-width: 160px; background: var(--card-base, #1e1e2e); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); padding: 4px; z-index: 10000; display: none; } .dc-lang-menu.open { display: block; } .dc-lang-option { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--fg); transition: background 0.15s ease; white-space: nowrap; } .dc-lang-option:hover { background: color-mix(in srgb, var(--accent) 15%, transparent); } .dc-lang-option.active { background: color-mix(in srgb, var(--accent) 25%, transparent); color: var(--accent); font-weight: 600; } .dc-lang-option .dc-lang-flag { font-size: 1.1rem; line-height: 1; } .dc-lang-option .dc-lang-check { margin-left: auto; opacity: 0; font-size: 0.8rem; } .dc-lang-option.active .dc-lang-check { opacity: 1; } .dc-lang-label-sm { background: none !important; border: none !important; color: var(--muted); font-size: 0.7rem; cursor: pointer; padding: 2px 6px; font-family: inherit; opacity: 0.8; text-align: center; } `; document.head.appendChild(style); } function buildMenu(current) { const menu = document.createElement('div'); menu.className = 'dc-lang-menu'; menu.setAttribute('role', 'menu'); for (const lang of LANGUAGES) { const opt = document.createElement('div'); opt.className = 'dc-lang-option' + (lang.code === current ? ' active' : ''); opt.setAttribute('role', 'menuitemradio'); opt.setAttribute('aria-checked', lang.code === current ? 'true' : 'false'); opt.dataset.lang = lang.code; opt.innerHTML = '' + lang.flag + '' + '' + lang.label + '' + '✓'; menu.appendChild(opt); } return menu; } async function selectLanguage(code) { if (!SUPPORTED.includes(code) || code === getCurrentLanguage()) return; // Persist locally immediately for instant reload localStorage.setItem(STORAGE_KEY, code); // Best-effort backend sync — don't block the reload on failure try { if (typeof postJSON === 'function') { await postJSON(CONFIG_ENDPOINT, { language: code }); } else if (typeof secureFetch === 'function') { await secureFetch(CONFIG_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ language: code }), }); } } catch (err) { // Non-fatal: localStorage already holds the preference console.warn('[LanguageSelector] backend sync failed:', err); } // Reload so the new language takes effect across the dashboard window.location.reload(); } function init() { injectStyles(); const current = getCurrentLanguage(); const meta = langMeta(current); const wrap = document.createElement('div'); wrap.className = 'dc-lang-wrap'; const btn = document.createElement('button'); btn.className = 'dc-lang-btn'; btn.type = 'button'; btn.setAttribute('aria-label', 'Select language'); btn.setAttribute('aria-haspopup', 'true'); btn.setAttribute('aria-expanded', 'false'); btn.title = 'Switch language'; btn.innerHTML = '' + meta.flag + '' + '' + current.toUpperCase() + '' + '▼'; const menu = buildMenu(current); // Small label beneath, matching the "Customize Theme" link style const label = document.createElement('span'); label.className = 'dc-lang-label-sm'; label.textContent = 'Language'; wrap.appendChild(btn); wrap.appendChild(label); wrap.appendChild(menu); // Toggle menu open/closed btn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = menu.classList.toggle('open'); btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); }); // Option clicks menu.addEventListener('click', (e) => { const opt = e.target.closest('.dc-lang-option'); if (!opt) return; const code = opt.dataset.lang; menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); selectLanguage(code); }); // Close when clicking outside document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); } }); // Close on Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); } }); return wrap; } // Expose for programmatic use / testing window.DCLanguageSelector = { getCurrentLanguage, selectLanguage, LANGUAGES, STORAGE_KEY, }; // Auto-mount into the navbar next to the theme toggle. // The dashboard loads core.js (globals) before this deferred script, but the // navbar container is always present in the initial HTML. function mount() { const group = document.querySelector('.theme-toggle-group'); if (group && group.parentNode) { // Insert immediately after the theme-toggle-group so it sits beside it group.parentNode.insertBefore(init(), group.nextSibling); return true; } return false; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount); } else { mount(); } console.log('[LanguageSelector] Module loaded — current language:', getCurrentLanguage()); })();