/** * Lightweight i18n (internationalization) system for Speakr * Handles loading and managing translations with template variable support */ class I18n { constructor() { this.translations = {}; this.currentLocale = 'en'; this.fallbackLocale = 'en'; this.loadedLocales = new Set(); } /** * Initialize i18n with default locale * @param {string} locale - Initial locale code (e.g., 'en', 'es', 'fr', 'zh') */ async init(locale = 'en') { // Get saved locale from localStorage or use browser language const savedLocale = localStorage.getItem('preferredLanguage'); const browserLocale = navigator.language.split('-')[0]; this.currentLocale = savedLocale || locale || browserLocale || 'en'; // Load the initial locale await this.loadLocale(this.currentLocale); // Load fallback locale if different if (this.currentLocale !== this.fallbackLocale) { await this.loadLocale(this.fallbackLocale); } } /** * Load translations for a specific locale * @param {string} locale - Locale code to load */ async loadLocale(locale) { if (this.loadedLocales.has(locale)) { return; // Already loaded } try { const response = await fetch(`/static/locales/${locale}.json`); if (!response.ok) { throw new Error(`Failed to load locale: ${locale}`); } const translations = await response.json(); this.translations[locale] = translations; this.loadedLocales.add(locale); console.log(`Loaded locale: ${locale}`); } catch (error) { console.error(`Error loading locale ${locale}:`, error); // If failed to load requested locale and it's not the fallback, try fallback if (locale !== this.fallbackLocale) { console.log(`Failed to load ${locale}, will use ${this.fallbackLocale} as fallback`); // Don't change currentLocale - keep user's preference // Just ensure fallback translations are available await this.loadLocale(this.fallbackLocale); } } } /** * Change the current locale * @param {string} locale - New locale code */ async setLocale(locale) { await this.loadLocale(locale); this.currentLocale = locale; localStorage.setItem('preferredLanguage', locale); // Dispatch custom event for locale change window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } })); } /** * Get the current locale * @returns {string} Current locale code */ getLocale() { return this.currentLocale; } /** * Get available locales * @returns {Array} List of available locale codes */ getAvailableLocales() { return [ { code: 'en', name: 'English', nativeName: 'English' }, { code: 'es', name: 'Spanish', nativeName: 'Español' }, { code: 'fr', name: 'French', nativeName: 'Français' }, { code: 'zh', name: 'Chinese', nativeName: '中文' }, { code: 'ru', name: 'Russian', nativeName: 'Русский' }, ]; } /** * Translate a key with optional parameters * @param {string} key - Translation key (e.g., 'common.save' or 'nav.upload') * @param {Object} params - Optional parameters for template replacement * @param {string} locale - Optional specific locale (defaults to current) * @returns {string} Translated text */ t(key, params = {}, locale = null) { const targetLocale = locale || this.currentLocale; // Get translation from current locale or fallback let translation = this.getNestedTranslation(targetLocale, key); if (!translation && targetLocale !== this.fallbackLocale) { translation = this.getNestedTranslation(this.fallbackLocale, key); } if (!translation) { console.warn(`Translation not found for key: ${key}`); return key; // Return the key itself as fallback } // Replace template variables return this.interpolate(translation, params); } /** * Get nested translation value from object * @param {string} locale - Locale to search in * @param {string} key - Dot-separated key path * @returns {string|null} Translation value or null */ getNestedTranslation(locale, key) { if (!this.translations[locale]) { return null; } const keys = key.split('.'); let value = this.translations[locale]; for (const k of keys) { if (value && typeof value === 'object' && k in value) { value = value[k]; } else { return null; } } return typeof value === 'string' ? value : null; } /** * Replace template variables in translation string * @param {string} text - Text with placeholders like {{variable}} * @param {Object} params - Parameters to replace * @returns {string} Interpolated text */ interpolate(text, params) { return text.replace(/\{\{(\w+)\}\}/g, (match, key) => { return params.hasOwnProperty(key) ? params[key] : match; }); } /** * Handle pluralization * @param {string} key - Base translation key * @param {number} count - Count for pluralization * @param {Object} params - Additional parameters * @returns {string} Translated text with proper pluralization */ tc(key, count, params = {}) { const pluralKey = count === 1 ? key : `${key}Plural`; return this.t(pluralKey, { ...params, count }); } /** * Format date according to locale * @param {Date|string} date - Date to format * @param {Object} options - Intl.DateTimeFormat options * @returns {string} Formatted date string */ formatDate(date, options = {}) { const d = date instanceof Date ? date : new Date(date); return new Intl.DateTimeFormat(this.currentLocale, options).format(d); } /** * Format number according to locale * @param {number} number - Number to format * @param {Object} options - Intl.NumberFormat options * @returns {string} Formatted number string */ formatNumber(number, options = {}) { return new Intl.NumberFormat(this.currentLocale, options).format(number); } /** * Format file size with appropriate unit * @param {number} bytes - Size in bytes * @returns {string} Formatted file size */ formatFileSize(bytes) { const units = ['bytes', 'kilobytes', 'megabytes', 'gigabytes']; const unitValues = [1, 1024, 1048576, 1073741824]; let unitIndex = 0; for (let i = unitValues.length - 1; i >= 0; i--) { if (bytes >= unitValues[i]) { unitIndex = i; break; } } const value = Math.round(bytes / unitValues[unitIndex] * 10) / 10; return this.t(`fileSize.${units[unitIndex]}`, { count: value }); } /** * Format duration with appropriate unit * @param {number} seconds - Duration in seconds * @returns {string} Formatted duration */ formatDuration(seconds) { if (seconds < 60) { return this.tc('duration.seconds', Math.round(seconds), { count: Math.round(seconds) }); } else if (seconds < 3600) { const minutes = Math.round(seconds / 60); return this.tc('duration.minutes', minutes, { count: minutes }); } else { const hours = Math.round(seconds / 3600 * 10) / 10; return this.tc('duration.hours', hours, { count: hours }); } } /** * Format relative time (e.g., "2 hours ago") * @param {Date|string} date - Date to format * @returns {string} Formatted relative time */ formatRelativeTime(date) { const d = date instanceof Date ? date : new Date(date); const now = new Date(); const diffSeconds = Math.floor((now - d) / 1000); if (diffSeconds < 60) { return this.t('time.justNow'); } else if (diffSeconds < 3600) { const minutes = Math.floor(diffSeconds / 60); return minutes === 1 ? this.t('time.minuteAgo') : this.t('time.minutesAgo', { count: minutes }); } else if (diffSeconds < 86400) { const hours = Math.floor(diffSeconds / 3600); return hours === 1 ? this.t('time.hourAgo') : this.t('time.hoursAgo', { count: hours }); } else if (diffSeconds < 604800) { const days = Math.floor(diffSeconds / 86400); return days === 1 ? this.t('time.dayAgo') : this.t('time.daysAgo', { count: days }); } else if (diffSeconds < 2592000) { const weeks = Math.floor(diffSeconds / 604800); return weeks === 1 ? this.t('time.weekAgo') : this.t('time.weeksAgo', { count: weeks }); } else if (diffSeconds < 31536000) { const months = Math.floor(diffSeconds / 2592000); return months === 1 ? this.t('time.monthAgo') : this.t('time.monthsAgo', { count: months }); } else { const years = Math.floor(diffSeconds / 31536000); return years === 1 ? this.t('time.yearAgo') : this.t('time.yearsAgo', { count: years }); } } } // Create global i18n instance const i18n = new I18n(); // Create a fallback t function immediately if (typeof window !== 'undefined') { // Ensure window.i18n exists with at least a basic t function window.i18n = i18n; // Add a fallback t function if the class method isn't ready if (!window.i18n.t) { window.i18n.t = function(key, params) { console.warn('i18n.t called before initialization, returning key:', key); return key; }; } }