Add internationalization (i18n) support with English and Russian translations
Some checks failed
Validate / validate (push) Failing after 7s
Some checks failed
Validate / validate (push) Failing after 7s
Implemented localization system similar to the media-server project pattern:
- Created locale JSON files for English (en.json) and Russian (ru.json)
- Added complete translations for all UI elements, buttons, labels, and messages
- Implemented locale management system with browser locale detection
- Added language selector dropdown in header
- Applied data-i18n, data-i18n-title, and data-i18n-placeholder attributes
- Translations stored in localStorage and persist across sessions
- Automatic language detection from browser settings
- All dynamic content (displays, devices, modals) now uses translation function
Translations cover:
- Authentication (login/logout)
- Displays (layout visualization, cards, labels)
- Devices (management, status, actions)
- Settings modal (brightness, device configuration)
- Calibration modal (LED mapping, testing)
- Error messages and notifications
- Server status and version information
The implementation uses:
- Simple t(key, params) translation function with parameter substitution
- Async locale loading from /static/locales/{locale}.json
- updateAllText() to refresh all UI elements when language changes
- Fallback to English if translation file fails to load
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,130 @@ const API_BASE = '/api/v1';
|
||||
let refreshInterval = null;
|
||||
let apiKey = null;
|
||||
|
||||
// Locale management
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
const supportedLocales = {
|
||||
'en': 'English',
|
||||
'ru': 'Русский'
|
||||
};
|
||||
|
||||
// Minimal inline fallback for critical UI elements
|
||||
const fallbackTranslations = {
|
||||
'app.title': 'WLED Screen Controller',
|
||||
'auth.placeholder': 'Enter your API key...',
|
||||
'auth.button.login': 'Login'
|
||||
};
|
||||
|
||||
// Translation function
|
||||
function t(key, params = {}) {
|
||||
let text = translations[key] || fallbackTranslations[key] || key;
|
||||
|
||||
// Replace parameters like {name}, {value}, etc.
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// Load translation file
|
||||
async function loadTranslations(locale) {
|
||||
try {
|
||||
const response = await fetch(`/static/locales/${locale}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${locale}.json`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error loading translations for ${locale}:`, error);
|
||||
// Fallback to English if loading fails
|
||||
if (locale !== 'en') {
|
||||
return await loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Detect browser locale
|
||||
function detectBrowserLocale() {
|
||||
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
||||
const langCode = browserLang.split('-')[0]; // 'en-US' -> 'en', 'ru-RU' -> 'ru'
|
||||
|
||||
// Only return if we support it
|
||||
return supportedLocales[langCode] ? langCode : 'en';
|
||||
}
|
||||
|
||||
// Initialize locale
|
||||
async function initLocale() {
|
||||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
|
||||
// Set locale
|
||||
async function setLocale(locale) {
|
||||
if (!supportedLocales[locale]) {
|
||||
locale = 'en';
|
||||
}
|
||||
|
||||
// Load translations for the locale
|
||||
translations = await loadTranslations(locale);
|
||||
|
||||
currentLocale = locale;
|
||||
document.documentElement.setAttribute('data-locale', locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
localStorage.setItem('locale', locale);
|
||||
|
||||
// Update all text
|
||||
updateAllText();
|
||||
|
||||
// Update locale select dropdown (if visible)
|
||||
updateLocaleSelect();
|
||||
}
|
||||
|
||||
// Change locale from dropdown
|
||||
function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
localStorage.setItem('locale', newLocale);
|
||||
setLocale(newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Update locale select dropdown
|
||||
function updateLocaleSelect() {
|
||||
const select = document.getElementById('locale-select');
|
||||
if (select) {
|
||||
select.value = currentLocale;
|
||||
}
|
||||
}
|
||||
|
||||
// Update all text on page
|
||||
function updateAllText() {
|
||||
// Update all elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// Update all elements with data-i18n-placeholder attribute
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
|
||||
// Update all elements with data-i18n-title attribute
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize app
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize locale first
|
||||
await initLocale();
|
||||
// Load API key from localStorage
|
||||
apiKey = localStorage.getItem('wled_api_key');
|
||||
|
||||
@@ -97,13 +219,13 @@ async function loadServerInfo() {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('server-version').textContent = `Version: ${data.version}`;
|
||||
document.getElementById('version-number').textContent = data.version;
|
||||
document.getElementById('server-status').textContent = '●';
|
||||
document.getElementById('server-status').className = 'status-badge online';
|
||||
} catch (error) {
|
||||
console.error('Failed to load server info:', error);
|
||||
document.getElementById('server-status').className = 'status-badge offline';
|
||||
showToast('Server offline', 'error');
|
||||
showToast(t('server.offline'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +246,8 @@ async function loadDisplays() {
|
||||
const container = document.getElementById('displays-list');
|
||||
|
||||
if (!data.displays || data.displays.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No displays available</div>';
|
||||
document.getElementById('display-layout-canvas').innerHTML = '<div class="loading">No displays available</div>';
|
||||
container.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||
document.getElementById('display-layout-canvas').innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,18 +256,18 @@ async function loadDisplays() {
|
||||
<div class="display-card">
|
||||
<div class="display-header">
|
||||
<div class="display-index">${display.name}</div>
|
||||
${display.is_primary ? '<span class="badge badge-primary">Primary</span>' : '<span class="badge badge-secondary">Secondary</span>'}
|
||||
${display.is_primary ? `<span class="badge badge-primary">${t('displays.badge.primary')}</span>` : `<span class="badge badge-secondary">${t('displays.badge.secondary')}</span>`}
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Resolution:</span>
|
||||
<span class="info-label">${t('displays.resolution')}</span>
|
||||
<span class="info-value">${display.width} × ${display.height}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Position:</span>
|
||||
<span class="info-label">${t('displays.position')}</span>
|
||||
<span class="info-value">(${display.x}, ${display.y})</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Display Index:</span>
|
||||
<span class="info-label">${t('displays.index')}</span>
|
||||
<span class="info-value">${display.index}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,9 +278,9 @@ async function loadDisplays() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load displays:', error);
|
||||
document.getElementById('displays-list').innerHTML =
|
||||
'<div class="loading">Failed to load displays</div>';
|
||||
`<div class="loading">${t('displays.failed')}</div>`;
|
||||
document.getElementById('display-layout-canvas').innerHTML =
|
||||
'<div class="loading">Failed to load layout</div>';
|
||||
`<div class="loading">${t('displays.failed')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +288,7 @@ function renderDisplayLayout(displays) {
|
||||
const canvas = document.getElementById('display-layout-canvas');
|
||||
|
||||
if (!displays || displays.length === 0) {
|
||||
canvas.innerHTML = '<div class="loading">No displays to visualize</div>';
|
||||
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user