Frontend: structured error handling, state fixes, accessibility, i18n

- Enhance fetchWithAuth with auto-401, retry w/ exponential backoff, timeout
- Remove ~40 manual 401 checks across 10 feature files
- Fix state: brightness cache setter, manual edit flag resets, static import
- Add ARIA: role=dialog/tablist, aria-modal, aria-labelledby, aria-selected
- Add focus trapping in Modal base class, aria-expanded on hint toggles
- Fix WCAG AA color contrast with --primary-text-color variable
- Add i18n pluralization (CLDR rules for en/ru), getCurrentLocale export
- Replace hardcoded strings in dashboard.js and profiles.js
- Add data-i18n-aria-label support, 20 new keys in en.json and ru.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 01:18:29 +03:00
parent 2b90fafb9c
commit 3ae20761a1
41 changed files with 355 additions and 248 deletions

View File

@@ -3,9 +3,8 @@
*/
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.js';
import { showToast } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh } from './tabs.js';
@@ -284,9 +283,9 @@ function formatUptime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
if (h > 0) return t('time.hours_minutes', { h, m });
if (m > 0) return t('time.minutes_seconds', { m, s });
return t('time.seconds', { s });
}
export async function loadDashboard(forceFullRender = false) {
@@ -297,11 +296,10 @@ export async function loadDashboard(forceFullRender = false) {
try {
const [targetsResp, profilesResp, devicesResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
fetch(`${API_BASE}/devices`, { headers: getHeaders() }).catch(() => null),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/profiles').catch(() => null),
fetchWithAuth('/devices').catch(() => null),
]);
if (targetsResp.status === 401) { handle401Error(); return; }
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
@@ -415,6 +413,7 @@ export async function loadDashboard(forceFullRender = false) {
startPerfPolling();
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load dashboard:', error);
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
} finally {
@@ -427,7 +426,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = '⚡';
const typeLabel = isLed ? 'LED' : 'Key Colors';
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
let subtitleParts = [typeLabel];
if (isLed) {
@@ -558,26 +557,23 @@ function renderDashboardProfile(profile) {
export async function dashboardToggleProfile(profileId, enable) {
try {
const endpoint = enable ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
const response = await fetchWithAuth(`/profiles/${profileId}/${endpoint}`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
loadDashboard();
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to toggle profile', 'error');
}
}
export async function dashboardStartTarget(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.started'), 'success');
loadDashboard();
@@ -586,17 +582,16 @@ export async function dashboardStartTarget(targetId) {
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to start processing', 'error');
}
}
export async function dashboardStopTarget(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.stopped'), 'success');
loadDashboard();
@@ -605,21 +600,22 @@ export async function dashboardStopTarget(targetId) {
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to stop processing', 'error');
}
}
export async function dashboardStopAll() {
try {
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
if (targetsResp.status === 401) { handle401Error(); return; }
const targetsResp = await fetchWithAuth('/picture-targets');
const data = await targetsResp.json();
const running = (data.targets || []).filter(t => t.id);
await Promise.all(running.map(t =>
fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {})
fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
));
loadDashboard();
} catch (error) {
if (error.isAuth) return;
showToast('Failed to stop all targets', 'error');
}
}