diff --git a/docs/i18n-implementation-summary.md b/docs/i18n-implementation-summary.md new file mode 100644 index 00000000..c929c76d --- /dev/null +++ b/docs/i18n-implementation-summary.md @@ -0,0 +1,193 @@ +# LoRA Manager i18n Implementation Summary + +## 📋 Overview + +Successfully implemented comprehensive internationalization (i18n) support for LoRA Manager UI with automatic browser language detection, supporting English and Simplified Chinese. + +## 🛠 Implementation Details + +### Core System Files + +1. **`static/js/i18n/index.js`** - Main i18n manager + - Automatic browser language detection + - Translation interpolation with parameters + - Locale-aware number, date, and file size formatting + - RTL language support framework + +2. **`static/js/i18n/locales/en.js`** - English translations + - Complete translation set for all UI elements + - Hierarchical key structure (common, header, loras, etc.) + +3. **`static/js/i18n/locales/zh-CN.js`** - Simplified Chinese translations + - Full Chinese translation coverage + - Cultural adaptation for UI elements + +4. **`static/js/utils/i18nHelpers.js`** - DOM helper utilities + - Automatic DOM text replacement with `data-i18n` attributes + - Dynamic search placeholder updates + - Bulk selection count updates + - Element creation helpers + +### Modified Files + +#### JavaScript Files (8 files modified) +- `static/js/core.js` - Core app initialization with i18n +- `static/js/components/Header.js` - Header component with i18n +- `static/js/managers/BulkManager.js` - Bulk operations with i18n +- `static/js/loras.js` - LoRA page initialization +- `static/js/checkpoints.js` - Checkpoints page initialization +- `static/js/embeddings.js` - Embeddings page initialization +- `static/js/recipes.js` - Recipes page initialization +- `static/js/statistics.js` - Statistics page initialization + +#### HTML Template Files (3 files modified) +- `templates/components/header.html` - Navigation and search elements +- `templates/components/controls.html` - Page controls and bulk operations +- `templates/components/context_menu.html` - Context menu items + +## 🌐 Language Support + +### Supported Languages +- **English (en)** - Default language, comprehensive coverage +- **Simplified Chinese (zh-CN)** - Complete translation with cultural adaptations +- **Fallback Support** - Graceful fallback to English for missing translations + +### Browser Language Detection +- Automatically detects browser language preference +- Supports both `zh-CN` and `zh` language codes (both map to Simplified Chinese) +- Falls back to English for unsupported languages + +## ✨ Features + +### Automatic Translation +- HTML elements with `data-i18n` attributes are automatically translated +- Support for different target attributes (textContent, placeholder, title, etc.) +- Parameter interpolation for dynamic content + +### Formatting Functions +- **File Size**: Locale-aware file size formatting (e.g., "1 MB" / "1 兆字节") +- **Numbers**: Decimal formatting according to locale standards +- **Dates**: Locale-specific date formatting + +### Dynamic Updates +- Search placeholders update based on current page +- Bulk selection counts update dynamically +- Theme toggle tooltips reflect current state + +## 🔧 Usage Examples + +### HTML Template Usage +```html + +LoRA Manager + + + + + + +``` + +### Placeholder and Attribute Translation + +For form inputs and other attributes: + +```html + + + 5 selected + `; + document.body.appendChild(container); + return container; +} + +// Test basic translation functionality +function testBasicTranslation() { + console.log('=== Testing Basic Translation ==='); + + // Test simple translation + const saveText = t('common.actions.save'); + console.log(`Save button text: ${saveText}`); + + // Test translation with parameters + const selectedText = t('loras.bulkOperations.selected', { count: 3 }); + console.log(`Selection text: ${selectedText}`); + + // Test non-existent key (should return the key itself) + const missingKey = t('non.existent.key'); + console.log(`Missing key: ${missingKey}`); +} + +// Test DOM translation +function testDOMTranslation() { + console.log('=== Testing DOM Translation ==='); + + const container = createMockDOM(); + + // Apply translations + initializePageI18n(); + + // Check if translations were applied + const titleElement = container.querySelector('[data-i18n="header.appTitle"]'); + const inputElement = container.querySelector('input[data-i18n="header.search.placeholder"]'); + const buttonElement = container.querySelector('[data-i18n="common.actions.save"]'); + + console.log(`Title: ${titleElement.textContent}`); + console.log(`Input placeholder: ${inputElement.placeholder}`); + console.log(`Button: ${buttonElement.textContent}`); + + // Clean up + document.body.removeChild(container); +} + +// Test formatting functions +function testFormatting() { + console.log('=== Testing Formatting Functions ==='); + + // Test file size formatting + const sizes = [0, 1024, 1048576, 1073741824]; + sizes.forEach(size => { + const formatted = formatFileSize(size); + console.log(`${size} bytes = ${formatted}`); + }); + + // Test date formatting + const date = new Date('2024-01-15T10:30:00'); + const formattedDate = formatDate(date, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + console.log(`Date: ${formattedDate}`); + + // Test number formatting + const number = 1234.567; + const formattedNumber = formatNumber(number, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + console.log(`Number: ${formattedNumber}`); +} + +// Test language detection +function testLanguageDetection() { + console.log('=== Testing Language Detection ==='); + console.log(`Detected language: ${i18n.getCurrentLocale()}`); + console.log(`Is RTL: ${i18n.isRTL()}`); + console.log(`Browser language: ${navigator.language}`); +} + +// Run all tests +function runTests() { + console.log('Starting i18n System Tests...'); + console.log('====================================='); + + testLanguageDetection(); + testBasicTranslation(); + testFormatting(); + + // Only test DOM if we're in a browser environment + if (typeof document !== 'undefined') { + testDOMTranslation(); + } + + console.log('====================================='); + console.log('i18n System Tests Completed!'); +} + +// Export for manual testing +export { runTests }; + +// Auto-run tests if this module is loaded directly +if (typeof window !== 'undefined' && window.location.search.includes('test=i18n')) { + document.addEventListener('DOMContentLoaded', runTests); +} diff --git a/static/js/utils/i18nHelpers.js b/static/js/utils/i18nHelpers.js new file mode 100644 index 00000000..ee7a0212 --- /dev/null +++ b/static/js/utils/i18nHelpers.js @@ -0,0 +1,197 @@ +/** + * DOM utilities for i18n text replacement + */ +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() { + // Find all elements with data-i18n attribute + const elements = document.querySelectorAll('[data-i18n]'); + + elements.forEach(element => { + const key = element.getAttribute('data-i18n'); + const params = element.getAttribute('data-i18n-params'); + + let parsedParams = {}; + if (params) { + try { + parsedParams = JSON.parse(params); + } catch (e) { + console.warn(`Invalid JSON in data-i18n-params for key ${key}:`, params); + } + } + + // Get translated text + const translatedText = i18n.t(key, parsedParams); + + // Handle different translation targets + const target = element.getAttribute('data-i18n-target') || 'textContent'; + + switch (target) { + case 'placeholder': + element.placeholder = translatedText; + break; + case 'title': + element.title = translatedText; + break; + case 'alt': + element.alt = translatedText; + break; + case 'innerHTML': + element.innerHTML = translatedText; + break; + case 'textContent': + default: + element.textContent = translatedText; + 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() { + // Translate all elements with data-i18n attributes + 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 + * @param {string} key - Translation key + * @param {Object} params - Parameters for interpolation + * @returns {string} Translated text + */ +export function t(key, params = {}) { + return i18n.t(key, params); +} diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 980ccb9a..696d1b63 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -6,57 +6,57 @@ View on Civitai -->
- Refresh Civitai Data + Refresh Civitai Data
- Re-link to Civitai + Re-link to Civitai
- Copy LoRA Syntax + Copy LoRA Syntax
- Send to Workflow (Append) + Send to Workflow (Append)
- Send to Workflow (Replace) + Send to Workflow (Replace)
- Open Examples Folder + Open Examples Folder
- Download Example Images + Download Example Images
- Replace Preview + Replace Preview
- Set Content Rating + Set Content Rating
- Move to Folder + Move to Folder
- Exclude Model + Exclude Model
- Delete Model + Delete Model
-

Set Content Rating

+

Set Content Rating

-
Current: Unknown
+
Current: Unknown
- - - - - + + + + +
diff --git a/templates/components/controls.html b/templates/components/controls.html index 7ead740c..70c7dcad 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -1,59 +1,59 @@
-
+
-