/** * DC-077: i18n Language Selector * * Compact dropdown in the navbar (next to the theme toggle) that lets users switch * the dashboard language. Supports all 31 backend languages with a searchable list. * * - 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 * - Search filter for quickly finding a language in the 31-option list * * 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', nativeLabel: 'English' }, { code: 'ar', flag: '🇸🇦', label: 'Arabic', nativeLabel: 'العربية' }, { code: 'bn', flag: '🇧🇩', label: 'Bengali', nativeLabel: 'বাংলা' }, { code: 'cs', flag: '🇨🇿', label: 'Czech', nativeLabel: 'Čeština' }, { code: 'da', flag: '🇩🇰', label: 'Danish', nativeLabel: 'Dansk' }, { code: 'de', flag: '🇩🇪', label: 'German', nativeLabel: 'Deutsch' }, { code: 'el', flag: '🇬🇷', label: 'Greek', nativeLabel: 'Ελληνικά' }, { code: 'es', flag: '🇪🇸', label: 'Spanish', nativeLabel: 'Español' }, { code: 'fa', flag: '🇮🇷', label: 'Persian', nativeLabel: 'فارسی' }, { code: 'fi', flag: '🇫🇮', label: 'Finnish', nativeLabel: 'Suomi' }, { code: 'fr', flag: '🇫🇷', label: 'French', nativeLabel: 'Français' }, { code: 'hi', flag: '🇮🇳', label: 'Hindi', nativeLabel: 'हिन्दी' }, { code: 'hu', flag: '🇭🇺', label: 'Hungarian', nativeLabel: 'Magyar' }, { code: 'id', flag: '🇮🇩', label: 'Indonesian', nativeLabel: 'Bahasa Indonesia' }, { code: 'it', flag: '🇮🇹', label: 'Italian', nativeLabel: 'Italiano' }, { code: 'ja', flag: '🇯🇵', label: 'Japanese', nativeLabel: '日本語' }, { code: 'ko', flag: '🇰🇷', label: 'Korean', nativeLabel: '한국어' }, { code: 'ms', flag: '🇲🇾', label: 'Malay', nativeLabel: 'Bahasa Melayu' }, { code: 'nl', flag: '🇳🇱', label: 'Dutch', nativeLabel: 'Nederlands' }, { code: 'no', flag: '🇳🇴', label: 'Norwegian', nativeLabel: 'Norsk' }, { code: 'pl', flag: '🇵🇱', label: 'Polish', nativeLabel: 'Polski' }, { code: 'pt', flag: '🇵🇹', label: 'Portuguese', nativeLabel: 'Português' }, { code: 'ro', flag: '🇷🇴', label: 'Romanian', nativeLabel: 'Română' }, { code: 'ru', flag: '🇷🇺', label: 'Russian', nativeLabel: 'Русский' }, { code: 'sv', flag: '🇸🇪', label: 'Swedish', nativeLabel: 'Svenska' }, { code: 'th', flag: '🇹🇭', label: 'Thai', nativeLabel: 'ไทย' }, { code: 'tr', flag: '🇹🇷', label: 'Turkish', nativeLabel: 'Türkçe' }, { code: 'uk', flag: '🇺🇦', label: 'Ukrainian', nativeLabel: 'Українська' }, { code: 'ur', flag: '🇵🇰', label: 'Urdu', nativeLabel: 'اردو' }, { code: 'vi', flag: '🇻🇳', label: 'Vietnamese', nativeLabel: 'Tiếng Việt' }, { code: 'zh', flag: '🇨🇳', label: 'Chinese', nativeLabel: '中文' }, ]; 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: 200px; max-height: 360px; display: flex; flex-direction: column; 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: flex; } .dc-lang-search { margin: 2px 0 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-base, #111); color: var(--fg); font-size: 0.82rem; font-family: inherit; outline: none; width: 100%; box-sizing: border-box; } .dc-lang-search:focus { border-color: var(--accent); } .dc-lang-list { overflow-y: auto; max-height: 280px; scrollbar-width: thin; } .dc-lang-list::-webkit-scrollbar { width: 5px; } .dc-lang-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } .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-option.dc-lang-focus, .dc-lang-option:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .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'); // Search input const search = document.createElement('input'); search.type = 'text'; search.className = 'dc-lang-search'; search.placeholder = 'Search language…'; search.setAttribute('aria-label', 'Search languages'); search.autocomplete = 'off'; // Scrollable option list const list = document.createElement('div'); list.className = 'dc-lang-list'; 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.setAttribute('tabindex', '-1'); opt.dataset.lang = lang.code; opt.dataset.search = (lang.label + ' ' + lang.nativeLabel + ' ' + lang.code).toLowerCase(); opt.innerHTML = '' + lang.flag + '' + '' + lang.nativeLabel + '' + lang.label + '' + '' + ''; list.appendChild(opt); } // Filter logic — extracted so we can reset from the open handler function applyFilter(query) { var q = (query || '').toLowerCase().trim(); list.querySelectorAll('.dc-lang-option').forEach(function (opt) { var match = !q || opt.dataset.search.indexOf(q) !== -1; opt.style.display = match ? '' : 'none'; }); } search.addEventListener('input', function () { applyFilter(this.value); }); // Prevent clicks on search from closing the menu search.addEventListener('click', function (e) { e.stopPropagation(); }); // Expose reset so init() can restore visibility when reopening menu._resetFilter = function () { search.value = ''; applyFilter(''); }; // ===== Keyboard navigation (Arrow Up/Down, Enter, Space) ===== function getVisibleOptions() { return Array.from(list.querySelectorAll('.dc-lang-option')).filter( function (o) { return o.style.display !== 'none'; } ); } function focusOption(opt) { if (!opt) return; var visible = getVisibleOptions(); visible.forEach(function (o) { o.classList.remove('dc-lang-focus'); }); opt.classList.add('dc-lang-focus'); opt.focus(); } search.addEventListener('keydown', function (e) { var visible = getVisibleOptions(); if (visible.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); focusOption(visible[0]); } else if (e.key === 'Enter') { e.preventDefault(); var active = list.querySelector('.dc-lang-option.active'); if (active && active.style.display !== 'none') selectLanguage(active.dataset.lang); } }); list.addEventListener('keydown', function (e) { var visible = getVisibleOptions(); var currentIdx = visible.indexOf(document.activeElement); if (e.key === 'ArrowDown') { e.preventDefault(); var next = visible[Math.min(currentIdx + 1, visible.length - 1)]; focusOption(next); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (currentIdx === 0) { search.focus(); } else { focusOption(visible[currentIdx - 1]); } } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); var opt = document.activeElement; if (opt && opt.classList.contains('dc-lang-option')) { selectLanguage(opt.dataset.lang); } } }); menu.appendChild(search); menu.appendChild(list); return { menu: menu, search: search }; } 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 built = buildMenu(current); const menu = built.menu; const searchInput = built.search; // 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'); if (isOpen) { // Reset filter: clear search text AND restore all hidden options if (typeof menu._resetFilter === 'function') { menu._resetFilter(); } searchInput.focus(); } }); // Option clicks (delegate to the list container) 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 — return focus to the trigger button for accessibility document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && menu.classList.contains('open')) { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); btn.focus(); } }); 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()); })();