From b2c4efab45a14d1907dc14804e5cb878997dafa5 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Sun, 31 Aug 2025 09:03:06 +0800 Subject: [PATCH] refactor(i18n): streamline i18n initialization and update translation methods --- locales/zh-CN.json | 2 +- static/js/checkpoints.js | 4 - static/js/components/Header.js | 57 ++--- static/js/embeddings.js | 4 - static/js/i18n/index.js | 65 ++++-- static/js/managers/BulkManager.js | 8 +- static/js/managers/SettingsManager.js | 4 +- static/js/recipes.js | 6 +- static/js/statistics.js | 4 - static/js/utils/i18nHelpers.js | 320 +++++++++----------------- templates/recipes.html | 4 +- 11 files changed, 196 insertions(+), 282 deletions(-) diff --git a/locales/zh-CN.json b/locales/zh-CN.json index e8199752..55f208ab 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -89,7 +89,7 @@ "placeholders": { "loras": "搜索 LoRA...", "recipes": "搜索配方...", - "checkpoints": "搜索Checkpoint...", + "checkpoints": "搜索 Checkpoint...", "embeddings": "搜索 Embedding..." }, "options": "搜索选项", diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 58c12c91..907e92a6 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -4,7 +4,6 @@ import { createPageControls } from './components/controls/index.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; -import { initializePageI18n } from './utils/i18nHelpers.js'; // Initialize the Checkpoints page class CheckpointsPageManager { @@ -37,9 +36,6 @@ class CheckpointsPageManager { // Initialize common page features appCore.initializePageFeatures(); - // Initialize i18n for the page - initializePageI18n(); - console.log('Checkpoints Manager initialized'); } } diff --git a/static/js/components/Header.js b/static/js/components/Header.js index 4e4053c8..c6c63185 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -4,7 +4,7 @@ import { SearchManager } from '../managers/SearchManager.js'; import { FilterManager } from '../managers/FilterManager.js'; import { initPageState } from '../state/index.js'; import { getStorageItem } from '../utils/storageHelpers.js'; -import { updateSearchPlaceholder } from '../utils/i18nHelpers.js'; +import { updateElementAttribute } from '../utils/i18nHelpers.js'; /** * Header.js - Manages the application header behavior across different pages @@ -49,18 +49,17 @@ export class HeaderManager { // Handle theme toggle const themeToggle = document.querySelector('.theme-toggle'); if (themeToggle) { - // Set initial state based on current theme const currentTheme = getStorageItem('theme') || 'auto'; themeToggle.classList.add(`theme-${currentTheme}`); - - // Set initial tooltip text + + // 使用i18nHelpers更新themeToggle的title this.updateThemeTooltip(themeToggle, currentTheme); - - themeToggle.addEventListener('click', () => { + + themeToggle.addEventListener('click', async () => { if (typeof toggleTheme === 'function') { const newTheme = toggleTheme(); - // Update tooltip based on next toggle action - this.updateThemeTooltip(themeToggle, newTheme); + // 使用i18nHelpers更新themeToggle的title + await this.updateThemeTooltip(themeToggle, newTheme); } }); } @@ -124,41 +123,43 @@ export class HeaderManager { // Hide search functionality on Statistics page this.updateHeaderForPage(); } - - updateHeaderForPage() { + + async updateHeaderForPage() { const headerSearch = document.getElementById('headerSearch'); - + const searchInput = headerSearch?.querySelector('#searchInput'); + const searchButtons = headerSearch?.querySelectorAll('button'); + const placeholderKey = 'header.search.placeholders.' + this.currentPage; + if (this.currentPage === 'statistics' && headerSearch) { headerSearch.classList.add('disabled'); - // Disable search functionality - const searchInput = headerSearch.querySelector('#searchInput'); - const searchButtons = headerSearch.querySelectorAll('button'); if (searchInput) { searchInput.disabled = true; - searchInput.placeholder = window.i18n?.t('header.search.notAvailable') || 'Search not available on statistics page'; + // 使用i18nHelpers更新placeholder + await updateElementAttribute(searchInput, 'placeholder', 'header.search.notAvailable', {}, 'Search not available on statistics page'); } - searchButtons.forEach(btn => btn.disabled = true); + searchButtons?.forEach(btn => btn.disabled = true); } else if (headerSearch) { headerSearch.classList.remove('disabled'); - // Re-enable search functionality - const searchInput = headerSearch.querySelector('#searchInput'); - const searchButtons = headerSearch.querySelectorAll('button'); if (searchInput) { searchInput.disabled = false; - // Update placeholder based on current page - updateSearchPlaceholder(window.location.pathname); + // 使用i18nHelpers更新placeholder + await updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, ''); } - searchButtons.forEach(btn => btn.disabled = false); + searchButtons?.forEach(btn => btn.disabled = false); } } - - updateThemeTooltip(themeToggle, currentTheme) { - if (!window.i18n) return; - + + async updateThemeTooltip(themeToggle, currentTheme) { + if (!themeToggle) return; + let key; if (currentTheme === 'light') { - themeToggle.title = window.i18n.t('header.theme.switchToDark'); + key = 'header.theme.switchToDark'; } else if (currentTheme === 'dark') { - themeToggle.title = window.i18n.t('header.theme.switchToLight'); + key = 'header.theme.switchToLight'; + } else { + key = 'header.theme.toggle'; } + // 使用i18nHelpers更新title + await updateElementAttribute(themeToggle, 'title', key, {}, ''); } } diff --git a/static/js/embeddings.js b/static/js/embeddings.js index 106e281b..c2276ce8 100644 --- a/static/js/embeddings.js +++ b/static/js/embeddings.js @@ -4,7 +4,6 @@ import { createPageControls } from './components/controls/index.js'; import { EmbeddingContextMenu } from './components/ContextMenu/index.js'; import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js'; import { MODEL_TYPES } from './api/apiConfig.js'; -import { initializePageI18n } from './utils/i18nHelpers.js'; // Initialize the Embeddings page class EmbeddingsPageManager { @@ -37,9 +36,6 @@ class EmbeddingsPageManager { // Initialize common page features appCore.initializePageFeatures(); - // Initialize i18n for the page - initializePageI18n(); - console.log('Embeddings Manager initialized'); } } diff --git a/static/js/i18n/index.js b/static/js/i18n/index.js index b996fcd2..4e2a8259 100644 --- a/static/js/i18n/index.js +++ b/static/js/i18n/index.js @@ -9,6 +9,8 @@ class I18nManager { this.locales = {}; this.translations = {}; this.loadedLocales = new Set(); + this.ready = false; + this.readyPromise = null; // Available locales configuration this.availableLocales = { @@ -25,8 +27,9 @@ class I18nManager { }; this.currentLocale = this.getLanguageFromSettings(); - // Initialize with current locale - this.initializeWithLocale(this.currentLocale); + + // Initialize with current locale and create ready promise + this.readyPromise = this.initializeWithLocale(this.currentLocale); } /** @@ -78,12 +81,43 @@ class I18nManager { try { this.translations = await this.loadLocale(locale); this.currentLocale = locale; + this.ready = true; + + // Dispatch ready event + window.dispatchEvent(new CustomEvent('i18nReady', { + detail: { language: locale } + })); } catch (error) { console.warn(`Failed to initialize with locale ${locale}, falling back to English`, error); this.translations = await this.loadLocale('en'); this.currentLocale = 'en'; + this.ready = true; + + window.dispatchEvent(new CustomEvent('i18nReady', { + detail: { language: 'en' } + })); } } + + /** + * Wait for i18n to be ready + * @returns {Promise} Promise that resolves when i18n is ready + */ + async waitForReady() { + if (this.ready) { + return Promise.resolve(); + } + return this.readyPromise; + } + + /** + * Check if i18n is ready + * @returns {boolean} True if ready + */ + isReady() { + return this.ready && this.translations && Object.keys(this.translations).length > 0; + } + /** * Get language from user settings with fallback to English * @returns {string} Language code @@ -124,8 +158,12 @@ class I18nManager { } try { + // Reset ready state + this.ready = false; + // Load the new locale - await this.initializeWithLocale(languageCode); + this.readyPromise = this.initializeWithLocale(languageCode); + await this.readyPromise; // Save to localStorage const STORAGE_PREFIX = 'lora_manager_'; @@ -172,6 +210,12 @@ class I18nManager { * @returns {string} Translated text */ t(key, params = {}) { + // If not ready, return key as fallback + if (!this.isReady()) { + console.warn(`i18n not ready, returning key: ${key}`); + return key; + } + const keys = key.split('.'); let value = this.translations; @@ -282,18 +326,11 @@ class I18nManager { /** * Initialize i18n from user settings * This prevents language flashing on page load + * @deprecated Use waitForReady() instead */ async initializeFromSettings() { - const targetLanguage = this.getLanguageFromSettings(); - - // Set language immediately without animation/transition - this.currentLocale = targetLanguage; - this.translations = this.locales[targetLanguage] || this.locales['en']; - - // Dispatch event to notify that language has been initialized - window.dispatchEvent(new CustomEvent('languageInitialized', { - detail: { language: targetLanguage } - })); + console.warn('initializeFromSettings() is deprecated, use waitForReady() instead'); + return this.waitForReady(); } } @@ -301,4 +338,4 @@ class I18nManager { export const i18n = new I18nManager(); // Export for global access (will be attached to window) -export default i18n; +export default i18n; \ No newline at end of file diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 4151e85e..2defae45 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -5,7 +5,7 @@ import { modalManager } from './ModalManager.js'; import { moveManager } from './MoveManager.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; -import { updateBulkSelectionCount } from '../utils/i18nHelpers.js'; +import { updateElementText } from '../utils/i18nHelpers.js'; export class BulkManager { constructor() { @@ -185,9 +185,9 @@ export class BulkManager { const countElement = document.getElementById('selectedCount'); if (countElement) { - // Use i18n helper to update the count text - updateBulkSelectionCount(state.selectedModels.size); - + // Use i18nHelpers.js to update the count text + updateElementText(countElement, 'loras.bulkOperations.selected', { count: state.selectedModels.size }); + const existingCaret = countElement.querySelector('.dropdown-caret'); if (existingCaret) { existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 2560b527..c0cc3d62 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -4,7 +4,7 @@ import { state } from '../state/index.js'; import { resetAndReload } from '../api/modelApiFactory.js'; import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; -import { switchLanguage } from '../utils/i18nHelpers.js'; +// import { switchLanguage } from '../utils/i18nHelpers.js'; export class SettingsManager { constructor() { @@ -982,7 +982,7 @@ export class SettingsManager { } // Switch language immediately - switchLanguage(selectedLanguage); + // switchLanguage(selectedLanguage); showToast('Language changed successfully.', 'success'); diff --git a/static/js/recipes.js b/static/js/recipes.js index c6486d0c..293bb8d9 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -2,13 +2,12 @@ import { appCore } from './core.js'; import { ImportManager } from './managers/ImportManager.js'; import { RecipeModal } from './components/RecipeModal.js'; -import { getCurrentPageState, state } from './state/index.js'; +import { getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js'; import { refreshRecipes } from './api/recipeApi.js'; -import { initializePageI18n } from './utils/i18nHelpers.js'; class RecipeManager { constructor() { @@ -55,9 +54,6 @@ class RecipeManager { // Initialize common page features appCore.initializePageFeatures(); - - // Initialize i18n for the page - initializePageI18n(); } _initSearchOptions() { diff --git a/static/js/statistics.js b/static/js/statistics.js index 582feca7..80646dd8 100644 --- a/static/js/statistics.js +++ b/static/js/statistics.js @@ -1,7 +1,6 @@ // Statistics page functionality import { appCore } from './core.js'; import { showToast } from './utils/uiHelpers.js'; -import { initializePageI18n } from './utils/i18nHelpers.js'; // Chart.js import (assuming it's available globally or via CDN) // If Chart.js isn't available, we'll need to add it to the project @@ -27,9 +26,6 @@ class StatisticsManager { // Initialize charts and visualizations this.initializeVisualizations(); - // Initialize i18n for the page - initializePageI18n(); - this.initialized = true; } diff --git a/static/js/utils/i18nHelpers.js b/static/js/utils/i18nHelpers.js index 582775e5..80431ed9 100644 --- a/static/js/utils/i18nHelpers.js +++ b/static/js/utils/i18nHelpers.js @@ -1,226 +1,118 @@ /** - * DOM utilities for i18n text replacement + * i18n utility functions for safe translation handling */ -import { i18n } from '../i18n/index.js'; /** - * Replace text content in DOM elements with translations - * Uses data-i18n attribute to specify translation keys - */ -export function translateDOM() { - if (!window.i18n) return; - - // Select all elements with data-i18n attributes, including optgroups and options - const elements = document.querySelectorAll('[data-i18n]'); - - elements.forEach(element => { - const key = element.getAttribute('data-i18n'); - const target = element.getAttribute('data-i18n-target') || 'textContent'; - - if (key) { - const translation = window.i18n.t(key); - - // Handle different target attributes - switch (target) { - case 'placeholder': - element.placeholder = translation; - break; - case 'title': - element.title = translation; - break; - case 'label': - // For optgroup elements - element.label = translation; - break; - case 'value': - element.value = translation; - break; - case 'textContent': - default: - element.textContent = translation; - break; - } - } - }); -} - -/** - * Update placeholder text based on current page - * @param {string} currentPath - Current page path - */ -export function updateSearchPlaceholder(currentPath) { - const searchInput = document.getElementById('searchInput'); - if (!searchInput) return; - - let placeholderKey = 'header.search.placeholder'; - - if (currentPath === '/loras') { - placeholderKey = 'header.search.placeholders.loras'; - } else if (currentPath === '/loras/recipes') { - placeholderKey = 'header.search.placeholders.recipes'; - } else if (currentPath === '/checkpoints') { - placeholderKey = 'header.search.placeholders.checkpoints'; - } else if (currentPath === '/embeddings') { - placeholderKey = 'header.search.placeholders.embeddings'; - } - - searchInput.placeholder = i18n.t(placeholderKey); -} - -/** - * Set text content for an element using i18n - * @param {Element|string} element - DOM element or selector - * @param {string} key - Translation key - * @param {Object} params - Translation parameters - */ -export function setTranslatedText(element, key, params = {}) { - const el = typeof element === 'string' ? document.querySelector(element) : element; - if (el) { - el.textContent = i18n.t(key, params); - } -} - -/** - * Set attribute value for an element using i18n - * @param {Element|string} element - DOM element or selector - * @param {string} attribute - Attribute name - * @param {string} key - Translation key - * @param {Object} params - Translation parameters - */ -export function setTranslatedAttribute(element, attribute, key, params = {}) { - const el = typeof element === 'string' ? document.querySelector(element) : element; - if (el) { - el.setAttribute(attribute, i18n.t(key, params)); - } -} - -/** - * Create a translated element - * @param {string} tagName - HTML tag name - * @param {string} key - Translation key - * @param {Object} params - Translation parameters - * @param {Object} attributes - Additional attributes - * @returns {Element} Created element - */ -export function createTranslatedElement(tagName, key, params = {}, attributes = {}) { - const element = document.createElement(tagName); - element.textContent = i18n.t(key, params); - - Object.entries(attributes).forEach(([attr, value]) => { - element.setAttribute(attr, value); - }); - - return element; -} - -/** - * Update bulk selection count text - * @param {number} count - Number of selected items - */ -export function updateBulkSelectionCount(count) { - const selectedCountElement = document.getElementById('selectedCount'); - if (selectedCountElement) { - const textNode = selectedCountElement.firstChild; - if (textNode && textNode.nodeType === Node.TEXT_NODE) { - textNode.textContent = i18n.t('loras.bulkOperations.selected', { count }); - } - } -} - -/** - * Format file size with localized units - * @param {number} bytes - Size in bytes - * @param {number} decimals - Number of decimal places - * @returns {string} Formatted size string - */ -export function formatFileSize(bytes, decimals = 2) { - return i18n.formatFileSize(bytes, decimals); -} - -/** - * Format date with current locale - * @param {Date|string|number} date - Date to format - * @param {Object} options - Intl.DateTimeFormat options - * @returns {string} Formatted date string - */ -export function formatDate(date, options = {}) { - return i18n.formatDate(date, options); -} - -/** - * Format number with current locale - * @param {number} number - Number to format - * @param {Object} options - Intl.NumberFormat options - * @returns {string} Formatted number string - */ -export function formatNumber(number, options = {}) { - return i18n.formatNumber(number, options); -} - -/** - * Initialize i18n for the page - * This should be called after DOM content is loaded - */ -export function initializePageI18n() { - // Always use the client-side i18n with user settings - if (window.i18n) { - // Translate DOM elements - translateDOM(); - - // Update search placeholder based on current page - const currentPath = window.location.pathname; - updateSearchPlaceholder(currentPath); - - // Set document direction for RTL languages - if (i18n.isRTL()) { - document.documentElement.setAttribute('dir', 'rtl'); - document.body.classList.add('rtl'); - } else { - document.documentElement.setAttribute('dir', 'ltr'); - document.body.classList.remove('rtl'); - } - } -} - -/** - * Helper function to get translation directly + * Safe translation function that waits for i18n to be ready * @param {string} key - Translation key * @param {Object} params - Parameters for interpolation - * @returns {string} Translated text + * @param {string} fallback - Fallback text if translation fails + * @returns {Promise} Translated text */ -export function t(key, params = {}) { - return i18n.t(key, params); +export async function safeTranslate(key, params = {}, fallback = null) { + if (!window.i18n) { + console.warn('i18n not available'); + return fallback || key; + } + + // Wait for i18n to be ready + await window.i18n.waitForReady(); + + const translation = window.i18n.t(key, params); + + // If translation returned the key (meaning not found), use fallback + if (translation === key && fallback) { + return fallback; + } + + return translation; } /** - * Switch language and retranslate the page - * @param {string} languageCode - The language code to switch to - * @returns {boolean} True if language switch was successful + * Update element text with translation + * @param {HTMLElement|string} element - Element or selector + * @param {string} key - Translation key + * @param {Object} params - Parameters for interpolation + * @param {string} fallback - Fallback text */ -export function switchLanguage(languageCode) { - if (i18n.setLanguage(languageCode)) { - // Retranslate the entire page - translateDOM(); - - // Update search placeholder based on current page - const currentPath = window.location.pathname; - updateSearchPlaceholder(currentPath); - - // Set document direction for RTL languages - if (i18n.isRTL()) { - document.documentElement.setAttribute('dir', 'rtl'); - document.body.classList.add('rtl'); - } else { - document.documentElement.setAttribute('dir', 'ltr'); - document.body.classList.remove('rtl'); - } - - // Dispatch a custom event for other components to react to language change - window.dispatchEvent(new CustomEvent('languageChanged', { - detail: { language: languageCode } - })); - - return true; - } - return false; +export async function updateElementText(element, key, params = {}, fallback = null) { + const el = typeof element === 'string' ? document.querySelector(element) : element; + if (!el) return; + + const text = await safeTranslate(key, params, fallback); + el.textContent = text; } + +/** + * Update element attribute with translation + * @param {HTMLElement|string} element - Element or selector + * @param {string} attribute - Attribute name (e.g., 'title', 'placeholder') + * @param {string} key - Translation key + * @param {Object} params - Parameters for interpolation + * @param {string} fallback - Fallback text + */ +export async function updateElementAttribute(element, attribute, key, params = {}, fallback = null) { + const el = typeof element === 'string' ? document.querySelector(element) : element; + if (!el) return; + + const text = await safeTranslate(key, params, fallback); + el.setAttribute(attribute, text); +} + +/** + * Create a reactive translation that updates when language changes + * @param {string} key - Translation key + * @param {Object} params - Parameters for interpolation + * @param {Function} callback - Callback function to call with translated text + */ +export function createReactiveTranslation(key, params = {}, callback) { + let currentLanguage = null; + + const updateTranslation = async () => { + if (!window.i18n) return; + + await window.i18n.waitForReady(); + const newLanguage = window.i18n.getCurrentLocale(); + + // Only update if language changed or first time + if (newLanguage !== currentLanguage) { + currentLanguage = newLanguage; + const translation = window.i18n.t(key, params); + callback(translation); + } + }; + + // Initial update + updateTranslation(); + + // Listen for language changes + window.addEventListener('languageChanged', updateTranslation); + window.addEventListener('i18nReady', updateTranslation); + + // Return cleanup function + return () => { + window.removeEventListener('languageChanged', updateTranslation); + window.removeEventListener('i18nReady', updateTranslation); + }; +} + +/** + * Batch update multiple elements with translations + * @param {Array} updates - Array of update configurations + * Each update should have: { element, key, type: 'text'|'attribute', attribute?, params?, fallback? } + */ +export async function batchUpdateTranslations(updates) { + if (!window.i18n) return; + + await window.i18n.waitForReady(); + + for (const update of updates) { + const { element, key, type = 'text', attribute, params = {}, fallback } = update; + + if (type === 'text') { + await updateElementText(element, key, params, fallback); + } else if (type === 'attribute' && attribute) { + await updateElementAttribute(element, attribute, key, params, fallback); + } + } +} \ No newline at end of file diff --git a/templates/recipes.html b/templates/recipes.html index 8ccd9b02..d5e1b4ae 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -48,8 +48,8 @@ -
- +
+