/* Bible in Your Voice — i18n engine * Exposes window.I18n. Auto-inits on DOMContentLoaded. * Usage: I18n.t('key'), I18n.getLang(), I18n.setLang('de'), I18n.ttsLang() */ (function () { 'use strict'; const SUPPORTED = ['en', 'de', 'es', 'fr', 'pt', 'ja', 'ko', 'ru', 'it', 'zh', 'zh-hant']; // ISO 3166-1 alpha-2 country codes used for /flags/{code}.svg paths const FLAG_CODES = { en: 'gb', de: 'de', es: 'es', fr: 'fr', pt: 'br', ja: 'jp', ko: 'kr', ru: 'ru', it: 'it', zh: 'cn', 'zh-hant': 'tw' }; const NAMES = { en: 'English', de: 'Deutsch', es: 'Español', fr: 'Français', pt: 'Português', ja: '日本語', ko: '한국어', ru: 'Русский', it: 'Italiano', zh: '中文', 'zh-hant': '中文(繁體)' }; // Map i18n code → TTS language name expected by /api/voice-clone const TTS_LANG = { en: 'English', de: 'German', es: 'Spanish', fr: 'French', pt: 'Portuguese', ja: 'Japanese', ko: 'Korean', ru: 'Russian', it: 'Italian', zh: 'Chinese', 'zh-hant': 'Chinese' }; var _lang = 'en'; var _activeFlag = 'us'; // which English flag variant is highlighted ('us' or 'gb') var _data = {}; var _fallback = {}; // English always kept as fallback var _cache = {}; // ── Language detection ────────────────────────────────────────────────────── function _detect() { // 1. URL ?lang= param (dev override) try { var qp = new URLSearchParams(location.search).get('lang'); if (qp && SUPPORTED.indexOf(qp) !== -1) return qp; } catch (e) {} // 2. Persisted manual choice try { var saved = localStorage.getItem('bivLang'); if (saved && SUPPORTED.indexOf(saved) !== -1) return saved; } catch (e) {} // 3. Browser language preference list var candidates = []; try { if (navigator.language) candidates.push(navigator.language); } catch (e) {} try { if (navigator.languages) candidates = candidates.concat(Array.from(navigator.languages)); } catch (e) {} for (var i = 0; i < candidates.length; i++) { var full = candidates[i].toLowerCase().replace('_', '-'); // Traditional Chinese locales (zh-TW, zh-HK, zh-Hant-*) → zh-hant if (full === 'zh-tw' || full === 'zh-hk' || full.indexOf('zh-hant') === 0) return 'zh-hant'; var code = full.split('-')[0]; if (SUPPORTED.indexOf(code) !== -1) return code; } return 'en'; } // ── JSON loader (cached) ──────────────────────────────────────────────────── // Allow pages to override the i18n directory by setting window.I18N_PATH // before this script loads (e.g. window.I18N_PATH = '/static/i18n/classics'). var _i18nPath = window.I18N_PATH || '/i18n'; async function _load(code) { if (_cache[code]) return _cache[code]; try { var r = await fetch(_i18nPath + '/' + code + '.json?v=3'); if (!r.ok) throw new Error('HTTP ' + r.status); var d = await r.json(); _cache[code] = d; return d; } catch (e) { console.warn('[i18n] Could not load ' + code + ':', e.message); return null; } } // ── DOM translation pass ──────────────────────────────────────────────────── function _applyDOM() { // DEBUG: trace account.nav_link lookup console.log('[i18n DEBUG] lang=' + _lang, 'account.nav_link in _data:', 'account.nav_link' in _data, '_data value:', _data['account.nav_link'], 'fallback value:', _fallback['account.nav_link'], 'I18n.t result:', I18n.t('account.nav_link')); // Text content document.querySelectorAll('[data-i18n]').forEach(function (el) { var key = el.getAttribute('data-i18n'); var val = I18n.t(key); if (val !== key) el.textContent = val; }); // HTML content (only for fully static strings — no user input in values) document.querySelectorAll('[data-i18n-html]').forEach(function (el) { var key = el.getAttribute('data-i18n-html'); var val = I18n.t(key); if (val !== key) el.innerHTML = val; }); // Input / textarea placeholders document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) { var key = el.getAttribute('data-i18n-placeholder'); var val = I18n.t(key); if (val !== key) el.placeholder = val; }); // aria-label attributes document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { var key = el.getAttribute('data-i18n-aria'); var val = I18n.t(key); if (val !== key) el.setAttribute('aria-label', val); }); document.documentElement.lang = _lang; _updateSwitcherBtn(); // Notify inline scripts that translations are ready document.dispatchEvent(new CustomEvent('i18n:applied', { detail: { lang: _lang } })); } function _updateSwitcherBtn() { // Highlight the active flag button (only one at a time, even for en/us+gb) document.querySelectorAll('#i18n-switcher button[data-lang]').forEach(function (btn) { var lang = btn.getAttribute('data-lang'); var flag = btn.getAttribute('data-flag'); var active = lang === _lang && (lang !== 'en' || flag === _activeFlag); btn.style.opacity = active ? '1' : '0.45'; btn.style.transform = active ? 'scale(1.25)' : 'scale(1)'; btn.style.outline = active ? '2px solid rgba(255,255,255,.7)' : 'none'; }); } // ── Language switcher widget ──────────────────────────────────────────────── function _mountSwitcher() { if (document.getElementById('i18n-switcher')) return; var wrap = document.createElement('div'); wrap.id = 'i18n-switcher'; wrap.setAttribute('role', 'toolbar'); wrap.setAttribute('aria-label', 'Select language'); wrap.style.cssText = [ 'position:fixed', 'top:12px', 'right:12px', 'z-index:99999', 'display:flex', 'align-items:center', 'gap:4px', 'padding:5px 8px', 'background:rgba(15,35,71,.55)', 'backdrop-filter:blur(10px)', '-webkit-backdrop-filter:blur(10px)', 'border:1px solid rgba(255,255,255,.18)', 'border-radius:24px', ].join(';'); // Visible flags: each entry is [langCode, flagFile, label]. // US flag appears first; both US and GB map to 'en' but only one highlights. var FLAG_ENTRIES = [['en', 'us', 'English (US)']]; SUPPORTED.forEach(function (code) { FLAG_ENTRIES.push([code, FLAG_CODES[code], NAMES[code]]); }); console.log('[i18n DEBUG] FLAG_ENTRIES:', JSON.stringify(FLAG_ENTRIES)); FLAG_ENTRIES.forEach(function (entry) { var code = entry[0], flag = entry[1], label = entry[2]; console.log('[i18n DEBUG] creating flag button: code=' + code + ' flag=' + flag + ' src=/static/flags/' + flag + '.svg'); var btn = document.createElement('button'); btn.setAttribute('data-lang', code); btn.setAttribute('data-flag', flag); btn.setAttribute('title', label); btn.setAttribute('aria-label', label); btn.style.cssText = [ 'background:none', 'border:none', 'cursor:pointer', 'padding:2px', 'border-radius:4px', 'transition:opacity .15s, transform .15s', 'outline-offset:2px', 'line-height:1', 'display:flex', 'align-items:center', ].join(';'); var img = document.createElement('img'); img.src = '/static/flags/' + flag + '.svg?v=2'; img.alt = label; img.width = 22; img.height = 16; img.style.cssText = 'display:block;border-radius:2px'; btn.appendChild(img); var isActive = code === _lang && (code !== 'en' || flag === _activeFlag); btn.style.opacity = isActive ? '1' : '0.45'; btn.style.transform = isActive ? 'scale(1.25)' : 'scale(1)'; btn.style.outline = isActive ? '2px solid rgba(255,255,255,.7)' : 'none'; btn.addEventListener('click', function () { if (code === 'en') _activeFlag = flag; I18n.setLang(code); }); wrap.appendChild(btn); }); document.body.appendChild(wrap); } // ── Public API ────────────────────────────────────────────────────────────── var I18n = { /** Translate a key, with optional {{variable}} interpolation */ t: function (key, vars) { var val = (key in _data) ? _data[key] : (key in _fallback) ? _fallback[key] : key; if (vars) { for (var k in vars) { val = val.split('{{' + k + '}}').join(String(vars[k])); } } return val; }, /** Current language code */ getLang: function () { return _lang; }, /** TTS language name for /api/voice-clone */ ttsLang: function () { return TTS_LANG[_lang] || 'Auto'; }, /** Switch language: loads JSON, applies DOM, persists choice */ setLang: async function (code) { if (SUPPORTED.indexOf(code) === -1) code = 'en'; _lang = code; try { localStorage.setItem('bivLang', code); } catch (e) {} var d = await _load(code); _data = d || _fallback; _applyDOM(); }, /** Detect language, load JSON, mount switcher, apply translations */ init: async function () { console.log('[i18n DEBUG] init() started, _i18nPath=' + _i18nPath); var enData = await _load('en'); console.log('[i18n DEBUG] en.json loaded:', enData ? Object.keys(enData).length + ' keys' : 'FAILED'); _fallback = enData || {}; _lang = _detect(); console.log('[i18n DEBUG] detected lang=' + _lang); if (_lang === 'en') { _data = _fallback; } else { var d = await _load(_lang); console.log('[i18n DEBUG] ' + _lang + '.json loaded:', d ? Object.keys(d).length + ' keys' : 'FAILED'); _data = d || _fallback; } console.log('[i18n DEBUG] calling _mountSwitcher()'); _mountSwitcher(); console.log('[i18n DEBUG] _mountSwitcher() done, #i18n-switcher children:', document.getElementById('i18n-switcher') ? document.getElementById('i18n-switcher').children.length : 'NOT FOUND'); _applyDOM(); console.log('[i18n DEBUG] init() complete'); }, }; window.I18n = I18n; // Auto-init when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { I18n.init(); }); } else { I18n.init(); } })();