Files
oscars-pool/public/js/localization.js
T
Mathieu Moran 4308d6babf Implement en/fr localization across admin and user UIs
Introduce client-side localization helper and en/fr locale bundles.

Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text.

Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations).

Update .gitignore to ignore editor metadata and localization helper artifacts.
2026-02-01 20:20:02 -05:00

307 lines
7.5 KiB
JavaScript

/**
* Lightweight i18n module for Oscar Pool
* Supports English (base) and French translations
*/
(function() {
'use strict';
const STORAGE_KEY = 'app_language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'fr'];
let currentLang = DEFAULT_LANG;
let translations = {};
let loadedLangs = new Set();
let initPromise = null;
/**
* Detect language from: localStorage → browser → default
*/
function detectLanguage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) {
return stored;
}
const browserLang = (navigator.language || navigator.userLanguage || '').split('-')[0].toLowerCase();
if (SUPPORTED_LANGS.includes(browserLang)) {
return browserLang;
}
return DEFAULT_LANG;
}
/**
* Load translation file for a language
*/
async function loadTranslations(lang) {
if (loadedLangs.has(lang)) return true;
try {
const res = await fetch(`/locales/${lang}.json`, { cache: 'default' });
if (!res.ok) {
console.warn(`i18n: Failed to load ${lang}.json (${res.status})`);
return false;
}
const data = await res.json();
translations[lang] = data;
loadedLangs.add(lang);
return true;
} catch (err) {
console.warn(`i18n: Error loading ${lang}.json`, err);
return false;
}
}
/**
* Translate a key with optional interpolation
* @param {string} key - Translation key (e.g., 'nav.logout')
* @param {Object} params - Optional parameters for interpolation
* @returns {string} - Translated string or key if not found
*/
function t(key, params = {}) {
if (!key) return '';
// Try current language, then fallback to default
let text = translations[currentLang]?.[key];
if (text === undefined && currentLang !== DEFAULT_LANG) {
text = translations[DEFAULT_LANG]?.[key];
}
if (text === undefined) {
// Return key as fallback (helps identify missing translations)
return key;
}
// Simple interpolation: {varName} → value
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
const regex = new RegExp(`\\{${k}\\}`, 'g');
text = text.replace(regex, String(params[k]));
});
}
return text;
}
/**
* Set the current language
*/
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
return true;
}
/**
* Get the current language
*/
function getLanguage() {
return currentLang;
}
/**
* Get list of supported languages
*/
function getSupportedLanguages() {
return [...SUPPORTED_LANGS];
}
/**
* Translate all elements with data-i18n attributes
*/
function translatePage() {
// Translate text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
// Check for interpolation params
const paramsAttr = el.getAttribute('data-i18n-params');
let params = {};
if (paramsAttr) {
try {
params = JSON.parse(paramsAttr);
} catch (_) {
// Ignore invalid JSON
}
}
const translated = t(key, params);
if (translated !== key) {
el.textContent = translated;
}
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.placeholder = translated;
}
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.setAttribute('aria-label', translated);
}
});
// Translate titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.title = translated;
}
});
// Translate option values in selects
document.querySelectorAll('option[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.textContent = translated;
}
});
// Mark body as ready (for CSS transitions)
document.body.classList.add('i18n-ready');
}
/**
* Update a single element's translation (for dynamic content)
*/
function translateElement(el, key, params = {}) {
if (!el || !key) return;
el.textContent = t(key, params);
}
/**
* Fetch language setting from server (optional, for admin-set language)
*/
async function fetchServerLanguage() {
try {
const res = await fetch('/api/settings/language', {
method: 'GET',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.language && SUPPORTED_LANGS.includes(data.language)) {
return data.language;
}
}
} catch (_) {
// Silently fail - use local detection
}
return null;
}
/**
* Initialize i18n system
* @param {string} forceLang - Optional language to force (bypasses detection)
*/
async function init(forceLang = null) {
// Prevent multiple concurrent initializations
if (initPromise) return initPromise;
initPromise = (async () => {
// Determine language to use
let lang = forceLang;
if (!lang) {
// Try server setting first (if user is logged in and admin set a language)
const serverLang = await fetchServerLanguage();
if (serverLang) {
lang = serverLang;
} else {
lang = detectLanguage();
}
}
// Always load default language as fallback
await loadTranslations(DEFAULT_LANG);
// Load current language if different
if (lang !== DEFAULT_LANG) {
const loaded = await loadTranslations(lang);
if (!loaded) {
lang = DEFAULT_LANG;
}
}
setLanguage(lang);
translatePage();
return lang;
})();
return initPromise;
}
/**
* Change language and re-translate page
*/
async function changeLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
// Load translations if not already loaded
if (!loadedLangs.has(lang)) {
const loaded = await loadTranslations(lang);
if (!loaded) return false;
}
setLanguage(lang);
translatePage();
return true;
}
/**
* Check if translations are loaded
*/
function isReady() {
return loadedLangs.size > 0;
}
// Expose globally
window.i18n = {
t,
init,
setLanguage,
getLanguage,
changeLanguage,
getSupportedLanguages,
translatePage,
translateElement,
loadTranslations,
fetchServerLanguage,
isReady,
DEFAULT_LANG,
SUPPORTED_LANGS
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
// DOM already loaded
init();
}
})();