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.
307 lines
7.5 KiB
JavaScript
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();
|
|
}
|
|
})();
|