diff --git a/package.json b/package.json index e6e4740..e87b012 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "watch-css": "sass src/scss/main.scss public/style.css --watch --style=expanded --source-map", "dev-with-css": "concurrently \"npm run watch-css\" \"npm run dev\"", "dev-with-css:ts": "concurrently \"npm run watch-css\" \"npm run dev:ts\"", - "build": "npm run build:ts && npm run build-css", + "build": "npm run build:ts && npm run build-css && npm run copy-i18n", "build:ts": "tsc", + "copy-i18n": "mkdir -p dist/i18n/locales && cp -r src/i18n/locales/* dist/i18n/locales/", "test": "jest --runInBand --forceExit", "test:coverage": "jest --coverage", "lint": "eslint src/ tests/", diff --git a/public/app.js b/public/app.js index 85e2ae4..28731af 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,9 @@ document.addEventListener('DOMContentLoaded', () => { + // Initialize language selector + if (window.i18n) { + window.i18n.createLanguageSelector('language-selector-container'); + } + const map = L.map('map').setView([42.96008, -85.67403], 10); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { @@ -82,7 +87,10 @@ document.addEventListener('DOMContentLoaded', () => { .then(locations => { showMarkers(locations); const countElement = document.getElementById('location-count'); - countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`; + const reportsText = locations.length === 1 ? + (window.t ? window.t('map.activeReports') : 'active report') : + (window.t ? window.t('map.activeReportsPlural') : 'active reports'); + countElement.textContent = `${locations.length} ${reportsText}`; // Add visual indicator of last update const now = new Date(); @@ -92,11 +100,13 @@ document.addEventListener('DOMContentLoaded', () => { minute: '2-digit', second: '2-digit' }); - countElement.title = `Last updated: ${timeStr}`; + const lastUpdatedText = window.t ? window.t('time.lastUpdated') : 'Last updated:'; + countElement.title = `${lastUpdatedText} ${timeStr}`; }) .catch(err => { console.error('Error fetching locations:', err); - document.getElementById('location-count').textContent = 'Error loading locations'; + const errorText = window.t ? window.t('table.errorLoading') : 'Error loading locations'; + document.getElementById('location-count').textContent = errorText; }); }; @@ -242,13 +252,15 @@ document.addEventListener('DOMContentLoaded', () => { .then(location => { // Immediately refresh all locations to show the new one refreshLocations(); - messageDiv.textContent = 'Location reported successfully!'; + const successText = window.t ? window.t('form.reportSubmittedMsg') : 'Location reported successfully!'; + messageDiv.textContent = successText; messageDiv.className = 'message success'; locationForm.reset(); }) .catch(err => { console.error('Error reporting location:', err); - messageDiv.textContent = 'Error reporting location.'; + const errorText = window.t ? window.t('form.submitError') : 'Error reporting location.'; + messageDiv.textContent = errorText; messageDiv.className = 'message error'; }) .finally(() => { diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..00538b4 --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,267 @@ +/** + * Frontend internationalization (i18n) service + * Provides translation support for client-side JavaScript + */ + +class I18nService { + constructor() { + this.translations = new Map(); + this.defaultLocale = 'en'; + this.availableLocales = ['en', 'es-MX']; + this.currentLocale = this.getStoredLocale() || this.detectBrowserLocale(); + this.loadTranslations(); + } + + /** + * Load translation data from the server + */ + async loadTranslations() { + for (const locale of this.availableLocales) { + try { + const response = await fetch(`/api/i18n/${locale}`); + if (response.ok) { + const translations = await response.json(); + this.translations.set(locale, translations); + console.log(`Loaded translations for locale: ${locale}`); + } + } catch (error) { + console.error(`Failed to load translations for locale ${locale}:`, error); + } + } + + // Trigger translation update after loading + this.updatePageTranslations(); + } + + /** + * Get a translated string by key path + */ + t(keyPath, locale = null, params = null) { + const targetLocale = locale || this.currentLocale; + const translations = this.translations.get(targetLocale) || this.translations.get(this.defaultLocale); + + if (!translations) { + console.warn(`No translations found for locale: ${targetLocale}`); + return keyPath; + } + + const keys = keyPath.split('.'); + let value = translations; + + for (const key of keys) { + value = value?.[key]; + if (value === undefined) { + // Fallback to default locale if key not found + if (targetLocale !== this.defaultLocale) { + return this.t(keyPath, this.defaultLocale, params); + } + console.warn(`Translation key not found: ${keyPath} for locale: ${targetLocale}`); + return keyPath; + } + } + + if (typeof value !== 'string') { + console.warn(`Translation value is not a string for key: ${keyPath}`); + return keyPath; + } + + // Replace parameters if provided + if (params) { + return this.replaceParams(value, params); + } + + return value; + } + + /** + * Replace parameters in translation strings + */ + replaceParams(text, params) { + return text.replace(/\{\{(\w+)\}\}/g, (match, key) => { + return params[key] || match; + }); + } + + /** + * Set the current locale and update the UI + */ + async setLocale(locale) { + if (!this.availableLocales.includes(locale)) { + console.warn(`Unsupported locale: ${locale}`); + return; + } + + this.currentLocale = locale; + this.storeLocale(locale); + + // Update HTML lang attribute + document.documentElement.lang = locale; + + // Update page translations + this.updatePageTranslations(); + + // Trigger custom event for components to update + window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } })); + } + + /** + * Get current locale + */ + getLocale() { + return this.currentLocale; + } + + /** + * Get available locales + */ + getAvailableLocales() { + return [...this.availableLocales]; + } + + /** + * Get locale display name + */ + getLocaleDisplayName(locale) { + const displayNames = { + 'en': 'English', + 'es-MX': 'Español (México)' + }; + return displayNames[locale] || locale; + } + + /** + * Detect browser's preferred locale + */ + detectBrowserLocale() { + const browserLang = navigator.language || navigator.languages?.[0]; + + if (!browserLang) { + return this.defaultLocale; + } + + // Check for exact match first + if (this.availableLocales.includes(browserLang)) { + return browserLang; + } + + // Check for language match (e.g., "es" matches "es-MX") + const languageCode = browserLang.split('-')[0]; + const matchingLocale = this.availableLocales.find(locale => + locale.startsWith(languageCode) + ); + + return matchingLocale || this.defaultLocale; + } + + /** + * Store locale preference in localStorage + */ + storeLocale(locale) { + try { + localStorage.setItem('preferredLocale', locale); + } catch (error) { + console.warn('Failed to store locale preference:', error); + } + } + + /** + * Get stored locale preference from localStorage + */ + getStoredLocale() { + try { + return localStorage.getItem('preferredLocale'); + } catch (error) { + console.warn('Failed to get stored locale preference:', error); + return null; + } + } + + /** + * Update page translations using data-i18n attributes + */ + updatePageTranslations() { + // Update elements with data-i18n attribute + document.querySelectorAll('[data-i18n]').forEach(element => { + const key = element.getAttribute('data-i18n'); + const translation = this.t(key); + + if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) { + element.placeholder = translation; + } else if (element.hasAttribute('title')) { + element.title = translation; + } else { + element.textContent = translation; + } + }); + + // Update elements with data-i18n-html attribute (for HTML content) + document.querySelectorAll('[data-i18n-html]').forEach(element => { + const key = element.getAttribute('data-i18n-html'); + const translation = this.t(key); + element.innerHTML = translation; + }); + + // Update page title if it has translation + const titleElement = document.querySelector('title[data-i18n]'); + if (titleElement) { + const key = titleElement.getAttribute('data-i18n'); + document.title = this.t(key); + } + + // Update meta description if it has translation + const metaDescription = document.querySelector('meta[name="description"][data-i18n]'); + if (metaDescription) { + const key = metaDescription.getAttribute('data-i18n'); + metaDescription.content = this.t(key); + } + } + + /** + * Create language selector dropdown + */ + createLanguageSelector(containerId) { + const container = document.getElementById(containerId); + if (!container) { + console.warn(`Container with id "${containerId}" not found`); + return; + } + + const select = document.createElement('select'); + select.id = 'language-selector'; + select.className = 'language-selector'; + select.title = this.t('common.selectLanguage'); + + // Add options for each available locale + this.availableLocales.forEach(locale => { + const option = document.createElement('option'); + option.value = locale; + option.textContent = this.getLocaleDisplayName(locale); + option.selected = locale === this.currentLocale; + select.appendChild(option); + }); + + // Add change event listener + select.addEventListener('change', (event) => { + this.setLocale(event.target.value); + }); + + container.appendChild(select); + } +} + +// Create global instance +window.i18n = new I18nService(); + +// Helper function for easier access +window.t = function(keyPath, locale = null, params = null) { + return window.i18n.t(keyPath, locale, params); +}; + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.i18n.loadTranslations(); + }); +} else { + window.i18n.loadTranslations(); +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index a90f1f7..6cad6f7 100644 --- a/public/index.html +++ b/public/index.html @@ -3,10 +3,10 @@
-Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)
+Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)
+