Commit avant changement d'agent vers devstral
This commit is contained in:
240
frontend/src/i18n.js
Normal file
240
frontend/src/i18n.js
Normal file
@ -0,0 +1,240 @@
|
||||
import { debug, debugError } from './debug.js';
|
||||
|
||||
/**
|
||||
* I18n - Internationalization manager for client-side translations
|
||||
*/
|
||||
class I18n {
|
||||
constructor() {
|
||||
this.translations = {};
|
||||
this.currentLang = this.getStoredLanguage() || this.detectBrowserLanguage() || 'en';
|
||||
this.fallbackLang = 'en';
|
||||
this.isLoaded = false;
|
||||
this.onLanguageChangeCallbacks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored language from localStorage
|
||||
*/
|
||||
getStoredLanguage() {
|
||||
try {
|
||||
return localStorage.getItem('language');
|
||||
} catch (e) {
|
||||
debugError('Failed to get stored language:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser language
|
||||
*/
|
||||
detectBrowserLanguage() {
|
||||
const browserLang = navigator.language || navigator.userLanguage;
|
||||
// Extract language code (e.g., "fr-FR" -> "fr")
|
||||
const langCode = browserLang.split('-')[0];
|
||||
debug(`Detected browser language: ${langCode}`);
|
||||
return langCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations from server
|
||||
*/
|
||||
async loadTranslations(lang = this.currentLang) {
|
||||
try {
|
||||
const response = await fetch(`/api/i18n/${lang}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load translations for ${lang}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.translations[lang] = data;
|
||||
this.isLoaded = true;
|
||||
debug(`✅ Loaded translations for language: ${lang}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError(`Failed to load translations for ${lang}:`, error);
|
||||
// Try to load fallback language if current language fails
|
||||
if (lang !== this.fallbackLang) {
|
||||
debug(`Attempting to load fallback language: ${this.fallbackLang}`);
|
||||
return this.loadTranslations(this.fallbackLang);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n system
|
||||
*/
|
||||
async init() {
|
||||
await this.loadTranslations(this.currentLang);
|
||||
|
||||
// Load fallback language if different from current
|
||||
if (this.currentLang !== this.fallbackLang && !this.translations[this.fallbackLang]) {
|
||||
await this.loadTranslations(this.fallbackLang);
|
||||
}
|
||||
|
||||
debug(`I18n initialized with language: ${this.currentLang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key with optional arguments for interpolation
|
||||
* @param {string} key - Translation key in dot notation (e.g., "menu.home")
|
||||
* @param {object} args - Optional arguments for variable interpolation
|
||||
* @returns {string} Translated string
|
||||
*/
|
||||
t(key, args = {}) {
|
||||
if (!this.isLoaded) {
|
||||
debug(`⚠️ Translations not loaded yet, returning key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Try current language first
|
||||
let translation = this.getTranslation(this.currentLang, key);
|
||||
|
||||
// Fallback to default language
|
||||
if (!translation && this.currentLang !== this.fallbackLang) {
|
||||
translation = this.getTranslation(this.fallbackLang, key);
|
||||
}
|
||||
|
||||
// Return key if no translation found
|
||||
if (!translation) {
|
||||
debug(`⚠️ Translation not found for key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
// Interpolate variables
|
||||
return this.interpolate(translation, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation by key using dot notation
|
||||
*/
|
||||
getTranslation(lang, key) {
|
||||
const langTranslations = this.translations[lang];
|
||||
if (!langTranslations) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = key.split('.');
|
||||
let current = langTranslations;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate variables in translation string
|
||||
* Replaces {{variable}} with actual values
|
||||
*/
|
||||
interpolate(str, args) {
|
||||
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return args[key] !== undefined ? args[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change current language
|
||||
*/
|
||||
async setLanguage(lang) {
|
||||
if (lang === this.currentLang) {
|
||||
debug(`Language already set to: ${lang}`);
|
||||
return;
|
||||
}
|
||||
|
||||
debug(`Changing language from ${this.currentLang} to ${lang}`);
|
||||
|
||||
// Load translations if not already loaded
|
||||
if (!this.translations[lang]) {
|
||||
const loaded = await this.loadTranslations(lang);
|
||||
if (!loaded) {
|
||||
debugError(`Failed to change language to ${lang}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentLang = lang;
|
||||
|
||||
// Store in localStorage
|
||||
try {
|
||||
localStorage.setItem('language', lang);
|
||||
} catch (e) {
|
||||
debugError('Failed to store language:', e);
|
||||
}
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
// Notify all registered callbacks
|
||||
this.notifyLanguageChange(lang);
|
||||
|
||||
debug(`✅ Language changed to: ${lang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be called when language changes
|
||||
*/
|
||||
onLanguageChange(callback) {
|
||||
this.onLanguageChangeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all callbacks about language change
|
||||
*/
|
||||
notifyLanguageChange(lang) {
|
||||
this.onLanguageChangeCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(lang);
|
||||
} catch (error) {
|
||||
debugError('Error in language change callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
*/
|
||||
getCurrentLanguage() {
|
||||
return this.currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
*/
|
||||
getAvailableLanguages() {
|
||||
return Object.keys(this.translations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate all elements with data-i18n attribute
|
||||
*/
|
||||
translatePage() {
|
||||
const elements = document.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(element => {
|
||||
const key = element.getAttribute('data-i18n');
|
||||
const translation = this.t(key);
|
||||
|
||||
// Check if we should set text content or placeholder
|
||||
if (element.hasAttribute('data-i18n-placeholder')) {
|
||||
element.placeholder = translation;
|
||||
} else {
|
||||
element.textContent = translation;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const i18n = new I18n();
|
||||
|
||||
// Export convenience function
|
||||
export const t = (key, args) => i18n.t(key, args);
|
||||
|
||||
// Initialize on import
|
||||
i18n.init().then(() => {
|
||||
debug('I18n system ready');
|
||||
});
|
||||
344
frontend/src/language-manager.js
Normal file
344
frontend/src/language-manager.js
Normal file
@ -0,0 +1,344 @@
|
||||
import { debug } from './debug.js';
|
||||
import { i18n, t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* LanguageManager - Manages language selection UI and persistence
|
||||
*/
|
||||
class LanguageManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
debug('LanguageManager initialized');
|
||||
|
||||
// Listen for language changes to update UI
|
||||
i18n.onLanguageChange((lang) => {
|
||||
this.updateUI(lang);
|
||||
this.reloadPageContent();
|
||||
});
|
||||
|
||||
// Listen to HTMX events to translate content after dynamic loads
|
||||
document.body.addEventListener('htmx:afterSwap', () => {
|
||||
debug('HTMX content swapped, translating UI...');
|
||||
// Wait a bit for DOM to be ready
|
||||
setTimeout(() => this.translateStaticUI(), 50);
|
||||
});
|
||||
|
||||
// Setup event listeners after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupEventListeners();
|
||||
// Translate UI on initial load once i18n is ready
|
||||
if (i18n.isLoaded) {
|
||||
this.translateStaticUI();
|
||||
} else {
|
||||
// Wait for i18n to load
|
||||
setTimeout(() => this.translateStaticUI(), 500);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.setupEventListeners();
|
||||
// Translate UI on initial load once i18n is ready
|
||||
if (i18n.isLoaded) {
|
||||
this.translateStaticUI();
|
||||
} else {
|
||||
// Wait for i18n to load
|
||||
setTimeout(() => this.translateStaticUI(), 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Language selector in settings modal
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.name === 'language') {
|
||||
const selectedLang = e.target.value;
|
||||
debug(`Language selected: ${selectedLang}`);
|
||||
i18n.setLanguage(selectedLang);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize language selector state
|
||||
this.updateUI(i18n.getCurrentLanguage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI to reflect current language
|
||||
*/
|
||||
updateUI(lang) {
|
||||
// Update radio buttons in settings
|
||||
const languageRadios = document.querySelectorAll('input[name="language"]');
|
||||
languageRadios.forEach(radio => {
|
||||
radio.checked = (radio.value === lang);
|
||||
});
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
debug(`UI updated for language: ${lang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload page content when language changes
|
||||
* This triggers HTMX to re-fetch content with new language
|
||||
*/
|
||||
reloadPageContent() {
|
||||
debug('Reloading page content with new language...');
|
||||
|
||||
// Translate all static UI elements immediately
|
||||
this.translateStaticUI();
|
||||
|
||||
// Reload the current view by triggering HTMX
|
||||
const editorContainer = document.getElementById('editor-container');
|
||||
if (editorContainer && window.htmx) {
|
||||
// Get current path from URL or default to home
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (currentPath === '/' || currentPath === '') {
|
||||
// Reload home view
|
||||
window.htmx.ajax('GET', '/api/home', {
|
||||
target: '#editor-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
} else if (currentPath.startsWith('/notes/')) {
|
||||
// Reload current note
|
||||
window.htmx.ajax('GET', `/api${currentPath}`, {
|
||||
target: '#editor-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reload file tree
|
||||
const fileTree = document.getElementById('file-tree');
|
||||
if (fileTree && window.htmx) {
|
||||
window.htmx.ajax('GET', '/api/tree', {
|
||||
target: '#file-tree',
|
||||
swap: 'outerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// Reload favorites
|
||||
const favoritesContent = document.getElementById('favorites-content');
|
||||
if (favoritesContent && window.htmx) {
|
||||
window.htmx.ajax('GET', '/api/favorites', {
|
||||
target: '#favorites-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
|
||||
// Translate all elements with data-i18n attributes
|
||||
i18n.translatePage();
|
||||
|
||||
debug('✅ Page content reloaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate all static UI elements (buttons, labels, etc.)
|
||||
*/
|
||||
translateStaticUI() {
|
||||
debug('Translating static UI elements...');
|
||||
|
||||
// 1. Translate all elements with data-i18n attributes
|
||||
const elementsWithI18n = document.querySelectorAll('[data-i18n]');
|
||||
elementsWithI18n.forEach(element => {
|
||||
const key = element.getAttribute('data-i18n');
|
||||
const translation = t(key);
|
||||
if (translation && translation !== key) {
|
||||
// Preserve emojis and icons at the start
|
||||
const currentText = element.textContent.trim();
|
||||
const emojiMatch = currentText.match(/^[\u{1F300}-\u{1F9FF}✨🏠📁📝⚙️🔍🎨🔤⌨️🌍]+/u);
|
||||
if (emojiMatch) {
|
||||
element.textContent = `${emojiMatch[0]} ${translation}`;
|
||||
} else {
|
||||
element.textContent = translation;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Translate placeholders with data-i18n-placeholder
|
||||
const elementsWithPlaceholder = document.querySelectorAll('[data-i18n-placeholder]');
|
||||
elementsWithPlaceholder.forEach(element => {
|
||||
const key = element.getAttribute('data-i18n-placeholder');
|
||||
const translation = t(key);
|
||||
if (translation && translation !== key) {
|
||||
element.placeholder = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Translate titles with data-i18n-title
|
||||
const elementsWithTitle = document.querySelectorAll('[data-i18n-title]');
|
||||
elementsWithTitle.forEach(element => {
|
||||
const key = element.getAttribute('data-i18n-title');
|
||||
const translation = t(key);
|
||||
if (translation && translation !== key) {
|
||||
element.title = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy: Direct element translation for backwards compatibility
|
||||
// Header buttons
|
||||
const homeButton = document.querySelector('button[hx-get="/api/home"]');
|
||||
if (homeButton && !homeButton.hasAttribute('data-i18n')) {
|
||||
homeButton.innerHTML = `🏠 ${t('menu.home')}`;
|
||||
}
|
||||
|
||||
const newNoteButton = document.querySelector('header button[onclick="showNewNoteModal()"]');
|
||||
if (newNoteButton && !newNoteButton.hasAttribute('data-i18n')) {
|
||||
newNoteButton.innerHTML = `✨ ${t('menu.newNote')}`;
|
||||
}
|
||||
|
||||
// Search placeholder
|
||||
const searchInput = document.querySelector('input[type="search"]');
|
||||
if (searchInput && !searchInput.hasAttribute('data-i18n-placeholder')) {
|
||||
searchInput.placeholder = t('search.placeholder');
|
||||
}
|
||||
|
||||
// New note modal
|
||||
const newNoteModal = document.getElementById('new-note-modal');
|
||||
if (newNoteModal) {
|
||||
const title = newNoteModal.querySelector('h2');
|
||||
if (title) title.textContent = `📝 ${t('newNoteModal.title')}`;
|
||||
|
||||
const label = newNoteModal.querySelector('label[for="note-name"]');
|
||||
if (label) label.textContent = t('newNoteModal.label');
|
||||
|
||||
const input = newNoteModal.querySelector('#note-name');
|
||||
if (input) input.placeholder = t('newNoteModal.placeholder');
|
||||
|
||||
const submitBtn = newNoteModal.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.textContent = t('newNoteModal.create');
|
||||
|
||||
const cancelBtn = newNoteModal.querySelector('button.secondary');
|
||||
if (cancelBtn) cancelBtn.textContent = t('newNoteModal.cancel');
|
||||
}
|
||||
|
||||
// New folder modal
|
||||
const newFolderModal = document.getElementById('new-folder-modal');
|
||||
if (newFolderModal) {
|
||||
const title = newFolderModal.querySelector('h2');
|
||||
if (title) title.textContent = `📁 ${t('newFolderModal.title')}`;
|
||||
|
||||
const label = newFolderModal.querySelector('label[for="folder-name"]');
|
||||
if (label) label.textContent = t('newFolderModal.label');
|
||||
|
||||
const input = newFolderModal.querySelector('#folder-name');
|
||||
if (input) input.placeholder = t('newFolderModal.placeholder');
|
||||
|
||||
const submitBtn = newFolderModal.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.textContent = t('newFolderModal.create');
|
||||
|
||||
const cancelBtn = newFolderModal.querySelector('button.secondary');
|
||||
if (cancelBtn) cancelBtn.textContent = t('newFolderModal.cancel');
|
||||
}
|
||||
|
||||
// Selection toolbar
|
||||
const deleteButton = document.querySelector('button[onclick="deleteSelected()"]');
|
||||
if (deleteButton) {
|
||||
const span = deleteButton.querySelector('svg + text') || deleteButton.lastChild;
|
||||
if (span && span.nodeType === Node.TEXT_NODE) {
|
||||
deleteButton.childNodes[deleteButton.childNodes.length - 1].textContent = t('selectionToolbar.delete');
|
||||
} else {
|
||||
// Si c'est dans un span ou autre
|
||||
const textNode = Array.from(deleteButton.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
|
||||
if (textNode) {
|
||||
textNode.textContent = ` ${t('selectionToolbar.delete')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSelectionButton = document.querySelector('button[onclick="cancelSelection()"]');
|
||||
if (cancelSelectionButton) {
|
||||
cancelSelectionButton.textContent = t('selectionToolbar.cancel');
|
||||
}
|
||||
|
||||
// Theme modal
|
||||
const modalTitle = document.querySelector('.theme-modal-content h2');
|
||||
if (modalTitle) {
|
||||
modalTitle.textContent = `⚙️ ${t('settings.title')}`;
|
||||
}
|
||||
|
||||
// Translate tabs
|
||||
const tabs = document.querySelectorAll('.settings-tab');
|
||||
if (tabs.length >= 4) {
|
||||
tabs[0].innerHTML = `🎨 ${t('tabs.themes')}`;
|
||||
tabs[1].innerHTML = `🔤 ${t('tabs.fonts')}`;
|
||||
tabs[2].innerHTML = `⌨️ ${t('tabs.shortcuts')}`;
|
||||
tabs[3].innerHTML = `⚙️ ${t('tabs.other')}`;
|
||||
}
|
||||
|
||||
// Translate close button in settings
|
||||
const closeButtons = document.querySelectorAll('.theme-modal-footer button');
|
||||
closeButtons.forEach(btn => {
|
||||
if (btn.getAttribute('onclick') === 'closeThemeModal()') {
|
||||
btn.textContent = t('settings.close');
|
||||
}
|
||||
});
|
||||
|
||||
// Translate language section heading
|
||||
const langSection = document.getElementById('other-section');
|
||||
if (langSection) {
|
||||
const heading = langSection.querySelector('h3');
|
||||
if (heading) {
|
||||
heading.textContent = `🌍 ${t('languages.title')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar sections
|
||||
const searchSectionTitle = document.querySelector('.sidebar-section-title');
|
||||
if (searchSectionTitle && searchSectionTitle.textContent.includes('🔍')) {
|
||||
searchSectionTitle.textContent = `🔍 ${t('search.title') || 'Recherche'}`;
|
||||
}
|
||||
|
||||
// Sidebar "Nouveau dossier" button
|
||||
const newFolderBtn = document.querySelector('.folder-create-btn');
|
||||
if (newFolderBtn && !newFolderBtn.hasAttribute('data-i18n')) {
|
||||
newFolderBtn.innerHTML = `📁 ${t('fileTree.newFolder')}`;
|
||||
}
|
||||
|
||||
// Sidebar "Paramètres" button span
|
||||
const settingsSpan = document.querySelector('#theme-settings-btn span');
|
||||
if (settingsSpan && !settingsSpan.hasAttribute('data-i18n')) {
|
||||
settingsSpan.textContent = t('settings.title');
|
||||
}
|
||||
|
||||
// Sidebar section titles with data-i18n
|
||||
const sidebarTitles = document.querySelectorAll('.sidebar-section-title[data-i18n]');
|
||||
sidebarTitles.forEach(title => {
|
||||
const key = title.getAttribute('data-i18n');
|
||||
const translation = t(key);
|
||||
if (translation && translation !== key) {
|
||||
const currentText = title.textContent.trim();
|
||||
const emojiMatch = currentText.match(/^[\u{1F300}-\u{1F9FF}⭐📅🔍]+/u);
|
||||
if (emojiMatch) {
|
||||
title.textContent = `${emojiMatch[0]} ${translation}`;
|
||||
} else {
|
||||
title.textContent = translation;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debug('✅ Static UI translated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
*/
|
||||
getCurrentLanguage() {
|
||||
return i18n.getCurrentLanguage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
*/
|
||||
getAvailableLanguages() {
|
||||
return i18n.getAvailableLanguages();
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const languageManager = new LanguageManager();
|
||||
|
||||
export default languageManager;
|
||||
export { languageManager };
|
||||
@ -1,3 +1,5 @@
|
||||
import './i18n.js';
|
||||
import './language-manager.js';
|
||||
import './editor.js';
|
||||
import './file-tree.js';
|
||||
import './ui.js';
|
||||
|
||||
@ -174,6 +174,10 @@ window.switchSettingsTab = function(tabName) {
|
||||
document.getElementById('themes-section').style.display = 'none';
|
||||
document.getElementById('fonts-section').style.display = 'none';
|
||||
document.getElementById('editor-section').style.display = 'none';
|
||||
const otherSection = document.getElementById('other-section');
|
||||
if (otherSection) {
|
||||
otherSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Activer l'onglet cliqué
|
||||
const activeTab = Array.from(tabs).find(tab => {
|
||||
@ -181,6 +185,7 @@ window.switchSettingsTab = function(tabName) {
|
||||
if (tabName === 'themes') return text.includes('thème');
|
||||
if (tabName === 'fonts') return text.includes('police');
|
||||
if (tabName === 'editor') return text.includes('éditeur');
|
||||
if (tabName === 'other') return text.includes('autre');
|
||||
return false;
|
||||
});
|
||||
if (activeTab) {
|
||||
|
||||
Reference in New Issue
Block a user