252 lines
7.3 KiB
JavaScript
252 lines
7.3 KiB
JavaScript
/**
|
|
* Internationalization (i18n) Service
|
|
* Handles translations and locale switching
|
|
* @module i18n
|
|
*/
|
|
|
|
/**
|
|
* @typedef {'de' | 'en' | 'fr' | 'it' | 'es' | 'pt' | 'ru'} Locale
|
|
*/
|
|
|
|
/**
|
|
* Mapping from short locale codes (used in frontend) to
|
|
* Directus long locale codes (used in categories_translations etc.)
|
|
* @type {Object<Locale, string>}
|
|
*/
|
|
const LOCALE_TO_DIRECTUS = {
|
|
de: 'de-DE',
|
|
en: 'en-US',
|
|
fr: 'fr-FR',
|
|
it: 'it-IT',
|
|
es: 'es-ES',
|
|
pt: 'pt-BR',
|
|
ru: 'ru-RU'
|
|
}
|
|
|
|
const DIRECTUS_TO_LOCALE = Object.fromEntries(
|
|
Object.entries(LOCALE_TO_DIRECTUS).map(([k, v]) => [v, k])
|
|
)
|
|
|
|
/**
|
|
* I18n Service class
|
|
* @class
|
|
*/
|
|
class I18n {
|
|
constructor() {
|
|
/** @type {Object<Locale, Object>} */
|
|
this.translations = {}
|
|
/** @type {Locale} */
|
|
this.currentLocale = 'de'
|
|
/** @type {Locale} */
|
|
this.fallbackLocale = 'de'
|
|
/** @type {Locale[]} */
|
|
this.supportedLocales = ['de', 'en', 'fr', 'it', 'es', 'pt', 'ru']
|
|
/** @type {Set<Function>} */
|
|
this.subscribers = new Set()
|
|
/** @type {boolean} */
|
|
this.loaded = false
|
|
}
|
|
|
|
/**
|
|
* Initialize i18n - load translations for detected locale
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async init() {
|
|
const savedLocale = localStorage.getItem('locale')
|
|
const browserLocale = navigator.language.split('-')[0]
|
|
|
|
this.currentLocale = savedLocale
|
|
|| (this.supportedLocales.includes(browserLocale) ? browserLocale : this.fallbackLocale)
|
|
|
|
await this.loadTranslations(this.currentLocale)
|
|
this.loaded = true
|
|
this.updateDOM()
|
|
}
|
|
|
|
/**
|
|
* Load translations for a locale
|
|
* @param {Locale} locale - Locale to load
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async loadTranslations(locale) {
|
|
if (this.translations[locale]) return
|
|
|
|
try {
|
|
const response = await fetch(`/locales/${locale}.json`)
|
|
if (!response.ok) throw new Error(`Failed to load ${locale}`)
|
|
this.translations[locale] = await response.json()
|
|
} catch (error) {
|
|
console.error(`Failed to load translations for ${locale}:`, error)
|
|
if (locale !== this.fallbackLocale) {
|
|
await this.loadTranslations(this.fallbackLocale)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change current locale
|
|
* @param {Locale} locale - New locale
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async setLocale(locale) {
|
|
if (!this.supportedLocales.includes(locale)) {
|
|
console.warn(`Locale ${locale} is not supported`)
|
|
return
|
|
}
|
|
|
|
await this.loadTranslations(locale)
|
|
this.currentLocale = locale
|
|
localStorage.setItem('locale', locale)
|
|
document.documentElement.lang = locale
|
|
|
|
this.updateDOM()
|
|
this.notifySubscribers()
|
|
}
|
|
|
|
/**
|
|
* Get current locale
|
|
* @returns {Locale}
|
|
*/
|
|
getLocale() {
|
|
return this.currentLocale
|
|
}
|
|
|
|
/**
|
|
* Translate a key with optional parameters
|
|
* @param {string} key - Translation key (e.g., 'home.title')
|
|
* @param {Object<string, string|number>} [params={}] - Interpolation params
|
|
* @returns {string} Translated text or key if not found
|
|
* @example
|
|
* t('listing.expiresInDays', { days: 5 }) // "Noch 5 Tage"
|
|
*/
|
|
t(key, params = {}) {
|
|
const translations = this.translations[this.currentLocale]
|
|
|| this.translations[this.fallbackLocale]
|
|
|| {}
|
|
|
|
let text = this.getNestedValue(translations, key)
|
|
|
|
if (text === undefined) {
|
|
console.warn(`Missing translation: ${key}`)
|
|
return key
|
|
}
|
|
|
|
Object.entries(params).forEach(([param, value]) => {
|
|
text = text.replace(new RegExp(`{{${param}}}`, 'g'), value)
|
|
})
|
|
|
|
return text
|
|
}
|
|
|
|
/**
|
|
* Get nested value from object by dot-notation path
|
|
* @private
|
|
* @param {Object} obj - Object to search
|
|
* @param {string} path - Dot-notation path (e.g., 'home.title')
|
|
* @returns {*} Value or undefined
|
|
*/
|
|
getNestedValue(obj, path) {
|
|
return path.split('.').reduce((current, key) => {
|
|
return current && current[key] !== undefined ? current[key] : undefined
|
|
}, obj)
|
|
}
|
|
|
|
/**
|
|
* Update all DOM elements with data-i18n attributes
|
|
* Called automatically on locale change
|
|
*/
|
|
updateDOM() {
|
|
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
|
const key = el.getAttribute('data-i18n')
|
|
let params = {}
|
|
if (el.dataset.i18nParams) {
|
|
try {
|
|
params = JSON.parse(el.dataset.i18nParams)
|
|
} catch (e) {
|
|
console.warn(`Invalid i18n params for key "${key}":`, e)
|
|
}
|
|
}
|
|
el.textContent = this.t(key, params)
|
|
})
|
|
|
|
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
|
|
const key = el.getAttribute('data-i18n-placeholder')
|
|
el.placeholder = this.t(key)
|
|
})
|
|
|
|
document.querySelectorAll('[data-i18n-title]').forEach((el) => {
|
|
const key = el.getAttribute('data-i18n-title')
|
|
el.title = this.t(key)
|
|
})
|
|
|
|
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
|
const key = el.getAttribute('data-i18n-aria')
|
|
el.setAttribute('aria-label', this.t(key))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Subscribe to locale changes
|
|
* @param {Function} callback - Called with new locale on change
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
subscribe(callback) {
|
|
this.subscribers.add(callback)
|
|
return () => this.subscribers.delete(callback)
|
|
}
|
|
|
|
/** @private */
|
|
notifySubscribers() {
|
|
this.subscribers.forEach(callback => callback(this.currentLocale))
|
|
}
|
|
|
|
/**
|
|
* Get list of supported locales
|
|
* @returns {Locale[]}
|
|
*/
|
|
getSupportedLocales() {
|
|
return this.supportedLocales
|
|
}
|
|
|
|
/**
|
|
* Get human-readable display name for a locale
|
|
* @param {Locale} locale
|
|
* @returns {string}
|
|
*/
|
|
getLocaleDisplayName(locale) {
|
|
const names = {
|
|
de: 'Deutsch',
|
|
en: 'English',
|
|
fr: 'Français',
|
|
it: 'Italiano',
|
|
es: 'Español',
|
|
pt: 'Português',
|
|
ru: 'Русский'
|
|
}
|
|
return names[locale] || locale
|
|
}
|
|
|
|
/**
|
|
* Get the Directus language code for a frontend locale
|
|
* @param {Locale} [locale] - Frontend locale (defaults to current)
|
|
* @returns {string} Directus language code (e.g. 'de-DE')
|
|
*/
|
|
getDirectusLocale(locale) {
|
|
return LOCALE_TO_DIRECTUS[locale || this.currentLocale] || LOCALE_TO_DIRECTUS[this.fallbackLocale]
|
|
}
|
|
|
|
/**
|
|
* Get the frontend locale for a Directus language code
|
|
* @param {string} directusCode - Directus code (e.g. 'it-IT')
|
|
* @returns {Locale}
|
|
*/
|
|
fromDirectusLocale(directusCode) {
|
|
return DIRECTUS_TO_LOCALE[directusCode] || directusCode?.split('-')[0] || this.fallbackLocale
|
|
}
|
|
}
|
|
|
|
export const i18n = new I18n()
|
|
export const t = (key, params) => i18n.t(key, params)
|
|
export const getCurrentLanguage = () => i18n.getLocale()
|
|
export { LOCALE_TO_DIRECTUS, DIRECTUS_TO_LOCALE }
|