All checks were successful
BotUI CI / build (push) Successful in 2m55s
- Increment CACHE_VERSION from v1 to v2 - Forces all users to fetch fresh translations from API - Fixes issue where old cache with placeholders was overriding correct HTML - Browser cache had stale translations from before i18n embed fix Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
315 lines
8.3 KiB
JavaScript
315 lines
8.3 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const DEFAULT_LOCALE = "en";
|
|
const STORAGE_KEY = "gb-locale";
|
|
const CACHE_VERSION = "v2";
|
|
const CACHE_TTL_MS = 3600000;
|
|
|
|
// IMPORTANT: Increment CACHE_VERSION when translation structure changes
|
|
// to invalidate all user caches and force fresh API fetches
|
|
// v2: Invalidated cache to fix placeholder translations after i18n embed fix
|
|
|
|
const MINIMAL_FALLBACK = {
|
|
"label-loading": "Loading...",
|
|
"status-error": "Error",
|
|
"action-retry": "Retry",
|
|
};
|
|
|
|
let currentLocale = DEFAULT_LOCALE;
|
|
let translations = {};
|
|
let isInitialized = false;
|
|
|
|
function detectBrowserLocale() {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
return stored;
|
|
}
|
|
|
|
const browserLang =
|
|
navigator.language || navigator.userLanguage || DEFAULT_LOCALE;
|
|
const shortLang = browserLang.split("-")[0];
|
|
|
|
const supportedLocales = ["en", "pt-BR", "es", "zh-CN"];
|
|
|
|
if (supportedLocales.includes(browserLang)) {
|
|
return browserLang;
|
|
}
|
|
|
|
const match = supportedLocales.find((loc) => loc.startsWith(shortLang));
|
|
return match || DEFAULT_LOCALE;
|
|
}
|
|
|
|
function getCacheKey(locale) {
|
|
return `gb-i18n-cache-${locale}}-${CACHE_VERSION}`;
|
|
}
|
|
|
|
function getCachedTranslations(locale) {
|
|
try {
|
|
const cached = localStorage.getItem(getCacheKey(locale));
|
|
if (cached) {
|
|
const { data, timestamp } = JSON.parse(cached);
|
|
const age = Date.now() - timestamp;
|
|
console.log(`i18n: Cache check for ${locale}: age=${Math.round(age/1000)}s, valid=${age < CACHE_TTL_MS}, keys=${Object.keys(data || {}).length}`);
|
|
if (age < CACHE_TTL_MS) {
|
|
return data;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("i18n: Failed to read cache", e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setCachedTranslations(locale, data) {
|
|
try {
|
|
localStorage.setItem(
|
|
getCacheKey(locale),
|
|
JSON.stringify({
|
|
data,
|
|
timestamp: Date.now(),
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.warn("i18n: Failed to write cache", e);
|
|
}
|
|
}
|
|
|
|
async function fetchTranslations(locale) {
|
|
try {
|
|
console.log(`i18n: Fetching translations for locale: ${locale}`);
|
|
const response = await fetch(`/api/i18n/${locale}`, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log(`i18n: Loaded ${Object.keys(result.translations || {}).length} translations for ${locale}`);
|
|
return result.translations || {};
|
|
} catch (e) {
|
|
console.error(`i18n: Failed to fetch translations for ${locale}`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadTranslations(locale) {
|
|
console.log(`i18n: loadTranslations called for locale: ${locale}`);
|
|
const cached = getCachedTranslations(locale);
|
|
if (cached) {
|
|
console.log(`i18n: Using cached translations for ${locale}`);
|
|
translations = cached;
|
|
currentLocale = locale;
|
|
return true;
|
|
}
|
|
|
|
console.log(`i18n: Cache miss, fetching from API for ${locale}`);
|
|
const fetched = await fetchTranslations(locale);
|
|
if (fetched && Object.keys(fetched).length > 0) {
|
|
translations = fetched;
|
|
currentLocale = locale;
|
|
setCachedTranslations(locale, fetched);
|
|
return true;
|
|
}
|
|
|
|
if (locale !== DEFAULT_LOCALE) {
|
|
console.warn(`i18n: Falling back to ${DEFAULT_LOCALE}`);
|
|
return loadTranslations(DEFAULT_LOCALE);
|
|
}
|
|
|
|
console.warn(`i18n: No translations found, using minimal fallback`);
|
|
translations = MINIMAL_FALLBACK;
|
|
return false;
|
|
}
|
|
|
|
function t(key, params) {
|
|
let text = translations[key] || MINIMAL_FALLBACK[key] || key;
|
|
|
|
if (!translations[key] && !MINIMAL_FALLBACK[key]) {
|
|
console.warn(`i18n: Missing translation key: ${key}`);
|
|
}
|
|
|
|
if (params && typeof params === "object") {
|
|
Object.keys(params).forEach((param) => {
|
|
text = text.replace(
|
|
new RegExp(`\\{\\s*\\$?${param}\\s*\\}`, "g"),
|
|
params[param],
|
|
);
|
|
text = text.replace(
|
|
new RegExp(`\\{\\s*${param}\\s*\\}`, "g"),
|
|
params[param],
|
|
);
|
|
});
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
function translateElement(element) {
|
|
const key = element.getAttribute("data-i18n");
|
|
if (key) {
|
|
const paramsAttr = element.getAttribute("data-i18n-params");
|
|
let params = null;
|
|
|
|
if (paramsAttr) {
|
|
try {
|
|
params = JSON.parse(paramsAttr);
|
|
} catch (e) {
|
|
console.warn("i18n: Invalid params JSON", paramsAttr);
|
|
}
|
|
}
|
|
|
|
element.textContent = t(key, params);
|
|
}
|
|
|
|
const placeholderKey = element.getAttribute("data-i18n-placeholder");
|
|
if (placeholderKey) {
|
|
element.setAttribute("placeholder", t(placeholderKey));
|
|
}
|
|
|
|
const titleKey = element.getAttribute("data-i18n-title");
|
|
if (titleKey) {
|
|
element.setAttribute("title", t(titleKey));
|
|
}
|
|
|
|
const ariaLabelKey = element.getAttribute("data-i18n-aria-label");
|
|
if (ariaLabelKey) {
|
|
element.setAttribute("aria-label", t(ariaLabelKey));
|
|
}
|
|
}
|
|
|
|
function translatePage(root) {
|
|
const container = root || document;
|
|
|
|
const elements = container.querySelectorAll(
|
|
"[data-i18n], [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]",
|
|
);
|
|
|
|
elements.forEach(translateElement);
|
|
}
|
|
|
|
async function setLocale(locale) {
|
|
if (locale === currentLocale && isInitialized) {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(STORAGE_KEY, locale);
|
|
await loadTranslations(locale);
|
|
translatePage();
|
|
|
|
document.documentElement.setAttribute("lang", locale.split("-")[0]);
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("localeChanged", {
|
|
detail: { locale: currentLocale },
|
|
}),
|
|
);
|
|
}
|
|
|
|
function setupBodyListeners() {
|
|
if (!document.body) {
|
|
return;
|
|
}
|
|
|
|
document.body.addEventListener("htmx:afterSwap", (event) => {
|
|
translatePage(event.detail.target);
|
|
});
|
|
|
|
document.body.addEventListener("htmx:afterSettle", (event) => {
|
|
translatePage(event.detail.target);
|
|
});
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
mutation.addedNodes.forEach((node) => {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
if (
|
|
node.hasAttribute &&
|
|
(node.hasAttribute("data-i18n") ||
|
|
node.hasAttribute("data-i18n-placeholder") ||
|
|
node.hasAttribute("data-i18n-title") ||
|
|
node.hasAttribute("data-i18n-aria-label"))
|
|
) {
|
|
translateElement(node);
|
|
}
|
|
if (node.querySelectorAll) {
|
|
translatePage(node);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
console.log("i18n: Initialization started");
|
|
if (isInitialized) {
|
|
console.log("i18n: Already initialized, skipping");
|
|
return;
|
|
}
|
|
|
|
const locale = detectBrowserLocale();
|
|
console.log(`i18n: Detected locale: ${locale}`);
|
|
await loadTranslations(locale);
|
|
|
|
isInitialized = true;
|
|
console.log(`i18n: Initialization complete, current locale: ${currentLocale}`);
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
translatePage();
|
|
setupBodyListeners();
|
|
});
|
|
} else {
|
|
translatePage();
|
|
setupBodyListeners();
|
|
}
|
|
}
|
|
|
|
async function getAvailableLocales() {
|
|
try {
|
|
const response = await fetch("/api/i18n/locales");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
return data.locales || ["en"];
|
|
}
|
|
} catch (e) {
|
|
console.warn("i18n: Failed to fetch available locales", e);
|
|
}
|
|
return ["en", "pt-BR", "es"];
|
|
}
|
|
|
|
function getCurrentLocale() {
|
|
return currentLocale;
|
|
}
|
|
|
|
function clearCache() {
|
|
const keys = Object.keys(localStorage);
|
|
keys.forEach((key) => {
|
|
if (key.startsWith("gb-i18n-cache-")) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
});
|
|
console.log("i18n: Cleared all translation caches");
|
|
}
|
|
|
|
window.i18n = {
|
|
t,
|
|
init,
|
|
setLocale,
|
|
getCurrentLocale,
|
|
getAvailableLocales,
|
|
translatePage,
|
|
translateElement,
|
|
clearCache,
|
|
};
|
|
|
|
init().catch((e) => console.error("i18n: Initialization failed", e));
|
|
})();
|