Add internationalization (i18n) support with English and Russian translations
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:
2026-02-06 17:09:50 +03:00
parent 6a0cc12ca1
commit 38018750ed
4 changed files with 439 additions and 90 deletions

View File

@@ -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;
}