Mobile UX Improvements: - Replace dropdown with icon-only flags on mobile (< 768px) - Add 44x44px touch targets for better mobile interaction - Add language selector to mobile menu drawer - Desktop keeps full dropdown with language names (≥ 768px) Language Selector Features: - Mobile navbar: Icon-only buttons (🇬🇧 🇩🇪 🇫🇷) - Desktop navbar: Dropdown with full text - Mobile drawer: Full language list with checkmarks - Active state: Blue ring around selected language - Auto-close drawer after language selection Accessibility: - ARIA labels on all buttons - aria-pressed state for current language - Minimum 44x44px touch targets (WCAG AA) - Keyboard navigation support maintained - Screen reader support with role="group" Technical Changes: - language-selector.js: Rewritten with responsive versions - navbar.js: Added mobile-menu-language-selector container - i18n-simple.js: Added languageChanged event dispatch UX Benefits: - Space savings: ~87px saved in mobile navbar - No crowding between language selector and hamburger menu - Flag emojis are universally recognizable - Touch-friendly buttons meet iOS/Android standards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
156 lines
3.8 KiB
JavaScript
156 lines
3.8 KiB
JavaScript
/**
|
|
* Simple i18n system for Tractatus
|
|
* Supports: en (English), de (German), fr (French), mi (Te Reo Māori - coming soon)
|
|
*/
|
|
|
|
const I18n = {
|
|
currentLang: 'en',
|
|
translations: {},
|
|
supportedLanguages: ['en', 'de', 'fr'],
|
|
|
|
async init() {
|
|
// 1. Detect language preference
|
|
this.currentLang = this.detectLanguage();
|
|
|
|
// 2. Load translations
|
|
await this.loadTranslations(this.currentLang);
|
|
|
|
// 3. Apply to page
|
|
this.applyTranslations();
|
|
|
|
// 4. Update language selector if present
|
|
this.updateLanguageSelector();
|
|
|
|
console.log(`[i18n] Initialized with language: ${this.currentLang}`);
|
|
},
|
|
|
|
detectLanguage() {
|
|
// Check localStorage first
|
|
const saved = localStorage.getItem('tractatus-lang');
|
|
if (saved && this.supportedLanguages.includes(saved)) {
|
|
return saved;
|
|
}
|
|
|
|
// Check browser language
|
|
const browserLang = (navigator.language || navigator.userLanguage).split('-')[0];
|
|
if (this.supportedLanguages.includes(browserLang)) {
|
|
return browserLang;
|
|
}
|
|
|
|
// Default to English
|
|
return 'en';
|
|
},
|
|
|
|
async loadTranslations(lang) {
|
|
try {
|
|
const response = await fetch(`/locales/${lang}/homepage.json`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load translations for ${lang}`);
|
|
}
|
|
this.translations = await response.json();
|
|
} catch (error) {
|
|
console.error(`[i18n] Error loading translations:`, error);
|
|
// Fallback to English if loading fails
|
|
if (lang !== 'en') {
|
|
this.currentLang = 'en';
|
|
await this.loadTranslations('en');
|
|
}
|
|
}
|
|
},
|
|
|
|
t(key) {
|
|
const keys = key.split('.');
|
|
let value = this.translations;
|
|
|
|
for (const k of keys) {
|
|
if (value && typeof value === 'object') {
|
|
value = value[k];
|
|
} else {
|
|
return key; // Return key if translation not found
|
|
}
|
|
}
|
|
|
|
return value || key;
|
|
},
|
|
|
|
applyTranslations() {
|
|
// Find all elements with data-i18n attribute
|
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
const key = el.dataset.i18n;
|
|
const translation = this.t(key);
|
|
|
|
if (typeof translation === 'string') {
|
|
el.textContent = translation;
|
|
}
|
|
});
|
|
|
|
// Handle data-i18n-html for HTML content
|
|
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
|
const key = el.dataset.i18nHtml;
|
|
const translation = this.t(key);
|
|
|
|
if (typeof translation === 'string') {
|
|
el.innerHTML = translation;
|
|
}
|
|
});
|
|
},
|
|
|
|
async setLanguage(lang) {
|
|
if (!this.supportedLanguages.includes(lang)) {
|
|
console.error(`[i18n] Unsupported language: ${lang}`);
|
|
return;
|
|
}
|
|
|
|
// Save preference
|
|
localStorage.setItem('tractatus-lang', lang);
|
|
|
|
// Update current language
|
|
this.currentLang = lang;
|
|
|
|
// Reload translations
|
|
await this.loadTranslations(lang);
|
|
|
|
// Reapply to page
|
|
this.applyTranslations();
|
|
|
|
// Update selector
|
|
this.updateLanguageSelector();
|
|
|
|
// Update HTML lang attribute
|
|
document.documentElement.lang = lang;
|
|
|
|
// Dispatch event for language change
|
|
window.dispatchEvent(new CustomEvent('languageChanged', {
|
|
detail: { language: lang }
|
|
}));
|
|
|
|
console.log(`[i18n] Language changed to: ${lang}`);
|
|
},
|
|
|
|
updateLanguageSelector() {
|
|
const selector = document.getElementById('language-selector');
|
|
if (selector) {
|
|
selector.value = this.currentLang;
|
|
}
|
|
},
|
|
|
|
getLanguageName(code) {
|
|
const names = {
|
|
'en': 'English',
|
|
'de': 'Deutsch',
|
|
'fr': 'Français',
|
|
'mi': 'Te Reo Māori'
|
|
};
|
|
return names[code] || code;
|
|
}
|
|
};
|
|
|
|
// Auto-initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => I18n.init());
|
|
} else {
|
|
I18n.init();
|
|
}
|
|
|
|
// Make available globally
|
|
window.I18n = I18n;
|