/** * 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} */ 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} */ 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} */ this.subscribers = new Set() /** @type {boolean} */ this.loaded = false } /** * Initialize i18n - load translations for detected locale * @returns {Promise} */ 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} */ 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} */ 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} [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 }