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

@@ -18,6 +18,7 @@
--text-color: #e0e0e0; --text-color: #e0e0e0;
--border-color: #404040; --border-color: #404040;
--display-badge-bg: rgba(0, 0, 0, 0.4); --display-badge-bg: rgba(0, 0, 0, 0.4);
--primary-text-color: #66bb6a;
color-scheme: dark; color-scheme: dark;
} }
@@ -28,6 +29,7 @@
--text-color: #333333; --text-color: #333333;
--border-color: #e0e0e0; --border-color: #e0e0e0;
--display-badge-bg: rgba(255, 255, 255, 0.85); --display-badge-bg: rgba(255, 255, 255, 0.85);
--primary-text-color: #3d8b40;
color-scheme: light; color-scheme: light;
} }

View File

@@ -40,7 +40,7 @@ section {
} }
.add-device-card:hover .add-device-icon { .add-device-card:hover .add-device-icon {
color: var(--primary-color); color: var(--primary-text-color);
} }
.add-device-label { .add-device-label {
@@ -86,7 +86,7 @@ section {
.card-tutorial-btn:hover { .card-tutorial-btn:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
color: var(--primary-color); color: var(--primary-text-color);
} }
@@ -119,7 +119,7 @@ section {
} }
.card-power-btn:hover { .card-power-btn:hover {
color: var(--primary-color); color: var(--primary-text-color);
background: rgba(76, 175, 80, 0.1); background: rgba(76, 175, 80, 0.1);
} }
@@ -319,7 +319,7 @@ section {
} }
.primary-star { .primary-star {
color: var(--primary-color); color: var(--primary-text-color);
font-size: 1.2rem; font-size: 1.2rem;
} }
@@ -400,7 +400,7 @@ section {
position: absolute; position: absolute;
top: 2px; top: 2px;
right: 4px; right: 4px;
color: var(--primary-color); color: var(--primary-text-color);
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.4); text-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
@@ -511,7 +511,7 @@ ul.section-tip li {
.metric-value { .metric-value {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-text-color);
} }
.metric-label { .metric-label {
@@ -535,7 +535,7 @@ ul.section-tip li {
.timing-total { .timing-total {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--primary-color); color: var(--primary-text-color);
} }
.timing-bar { .timing-bar {

View File

@@ -133,7 +133,7 @@
.dashboard-metric-value { .dashboard-metric-value {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-text-color);
line-height: 1.2; line-height: 1.2;
} }

View File

@@ -16,7 +16,7 @@ header {
h1 { h1 {
font-size: 2rem; font-size: 2rem;
color: var(--primary-color); color: var(--primary-text-color);
} }
h2 { h2 {
@@ -103,7 +103,7 @@ h2 {
.health-latency { .health-latency {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 400; font-weight: 400;
color: #4CAF50; color: var(--primary-text-color);
margin-left: auto; margin-left: auto;
padding-left: 8px; padding-left: 8px;
opacity: 0.85; opacity: 0.85;
@@ -140,7 +140,7 @@ h2 {
} }
.tab-btn.active { .tab-btn.active {
color: var(--primary-color); color: var(--primary-text-color);
border-bottom-color: var(--primary-color); border-bottom-color: var(--primary-color);
} }
@@ -190,7 +190,7 @@ h2 {
} }
.footer-content a { .footer-content a {
color: var(--primary-color); color: var(--primary-text-color);
text-decoration: none; text-decoration: none;
transition: opacity 0.2s; transition: opacity 0.2s;
} }

View File

@@ -185,7 +185,7 @@
.hint-toggle.active { .hint-toggle.active {
opacity: 1; opacity: 1;
color: var(--primary-color, #4CAF50); color: var(--primary-text-color, #4CAF50);
border-color: var(--primary-color, #4CAF50); border-color: var(--primary-color, #4CAF50);
} }

View File

@@ -50,7 +50,7 @@
} }
.add-template-card:hover .add-template-icon { .add-template-card:hover .add-template-icon {
color: var(--primary-color); color: var(--primary-text-color);
} }
.add-template-label { .add-template-label {
@@ -126,7 +126,7 @@
.template-config-details summary { .template-config-details summary {
cursor: pointer; cursor: pointer;
color: var(--primary-color); color: var(--primary-text-color);
font-weight: 500; font-weight: 500;
padding: 4px 0; padding: 4px 0;
} }
@@ -138,7 +138,7 @@
.template-no-config { .template-no-config {
margin: 12px 0; margin: 12px 0;
font-size: 13px; font-size: 13px;
color: var(--primary-color); color: var(--primary-text-color);
font-weight: 500; font-weight: 500;
padding: 4px 0; padding: 4px 0;
} }
@@ -558,7 +558,7 @@
} }
.stream-tab-btn.active { .stream-tab-btn.active {
color: var(--primary-color); color: var(--primary-text-color);
border-bottom-color: var(--primary-color); border-bottom-color: var(--primary-color);
} }

View File

@@ -5,7 +5,7 @@
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--primary-color); border: 2px solid var(--primary-color);
background: transparent; background: transparent;
color: var(--primary-color); color: var(--primary-text-color);
font-size: 1rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
@@ -95,7 +95,7 @@
.tutorial-step-counter { .tutorial-step-counter {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: var(--primary-color); color: var(--primary-text-color);
} }
.tutorial-close-btn { .tutorial-close-btn {

View File

@@ -2,7 +2,7 @@
* API utilities — base URL, auth headers, fetch wrapper, helpers. * API utilities — base URL, auth headers, fetch wrapper, helpers.
*/ */
import { apiKey, setApiKey, refreshInterval, setRefreshInterval } from './state.js'; import { apiKey, setApiKey, refreshInterval, setRefreshInterval, set_cachedDisplays } from './state.js';
export const API_BASE = '/api/v1'; export const API_BASE = '/api/v1';
@@ -16,12 +16,51 @@ export function getHeaders() {
return headers; return headers;
} }
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = 'ApiError';
this.status = status;
this.isAuth = status === 401;
}
}
export async function fetchWithAuth(url, options = {}) { export async function fetchWithAuth(url, options = {}) {
const { retry = true, timeout = 10000, handle401: auto401 = true, ...fetchOpts } = options;
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
const headers = options.headers const headers = fetchOpts.headers
? { ...getHeaders(), ...options.headers } ? { ...getHeaders(), ...fetchOpts.headers }
: getHeaders(); : getHeaders();
return fetch(fullUrl, { ...options, headers });
const maxAttempts = retry ? 3 : 1;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const controller = new AbortController();
if (fetchOpts.signal) {
fetchOpts.signal.addEventListener('abort', () => controller.abort());
}
const timer = setTimeout(() => controller.abort(), timeout);
try {
const resp = await fetch(fullUrl, { ...fetchOpts, headers, signal: controller.signal });
clearTimeout(timer);
if (resp.status === 401 && auto401) {
handle401Error();
throw new ApiError(401, 'Session expired');
}
if (resp.status >= 500 && attempt < maxAttempts - 1) {
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue;
}
return resp;
} catch (err) {
clearTimeout(timer);
if (err instanceof ApiError) throw err;
if (attempt < maxAttempts - 1) {
await new Promise(r => setTimeout(r, 500 * 2 ** attempt));
continue;
}
throw err;
}
}
} }
export function escapeHtml(text) { export function escapeHtml(text) {
@@ -36,6 +75,7 @@ export function isSerialDevice(type) {
} }
export function handle401Error() { export function handle401Error() {
if (!apiKey) return; // Already handled or no session
localStorage.removeItem('wled_api_key'); localStorage.removeItem('wled_api_key');
setApiKey(null); setApiKey(null);
@@ -80,23 +120,14 @@ export async function loadServerInfo() {
export async function loadDisplays() { export async function loadDisplays() {
try { try {
const response = await fetch(`${API_BASE}/config/displays`, { const response = await fetchWithAuth('/config/displays');
headers: getHeaders()
});
if (response.status === 401) {
handle401Error();
return;
}
const data = await response.json(); const data = await response.json();
if (data.displays && data.displays.length > 0) { if (data.displays && data.displays.length > 0) {
// Import setter to update shared state
const { set_cachedDisplays } = await import('./state.js');
set_cachedDisplays(data.displays); set_cachedDisplays(data.displays);
} }
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.isAuth) return;
console.error('Failed to load displays:', error); console.error('Failed to load displays:', error);
} }
} }

View File

@@ -17,8 +17,26 @@ const fallbackTranslations = {
'auth.button.login': 'Login' 'auth.button.login': 'Login'
}; };
export function getCurrentLocale() { return currentLocale; }
function getPluralForm(locale, count) {
if (locale === 'ru') {
const mod10 = count % 10, mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return 'one';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few';
return 'many';
}
return count === 1 ? 'one' : 'other';
}
export function t(key, params = {}) { export function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key; let text;
if ('count' in params) {
const form = getPluralForm(currentLocale, params.count);
text = translations[`${key}.${form}`] || translations[key] || fallbackTranslations[key] || key;
} else {
text = translations[key] || fallbackTranslations[key] || key;
}
Object.keys(params).forEach(param => { Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
}); });
@@ -99,6 +117,11 @@ export function updateAllText() {
el.title = t(key); el.title = t(key);
}); });
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
el.setAttribute('aria-label', t(key));
});
// Notify subscribers that the language changed (skip initial load) // Notify subscribers that the language changed (skip initial load)
if (_initialized) { if (_initialized) {
document.dispatchEvent(new CustomEvent('languageChanged')); document.dispatchEvent(new CustomEvent('languageChanged'));

View File

@@ -6,7 +6,7 @@
*/ */
import { t } from './i18n.js'; import { t } from './i18n.js';
import { lockBody, unlockBody, setupBackdropClose, showConfirm } from './ui.js'; import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.js';
export class Modal { export class Modal {
static _stack = []; static _stack = [];
@@ -25,20 +25,27 @@ export class Modal {
} }
open() { open() {
this._previousFocus = document.activeElement;
this.el.style.display = 'flex'; this.el.style.display = 'flex';
if (this._lock) lockBody(); if (this._lock) lockBody();
if (this._backdrop) setupBackdropClose(this.el, () => this.close()); if (this._backdrop) setupBackdropClose(this.el, () => this.close());
trapFocus(this.el);
Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack = Modal._stack.filter(m => m !== this);
Modal._stack.push(this); Modal._stack.push(this);
} }
forceClose() { forceClose() {
releaseFocus(this.el);
this.el.style.display = 'none'; this.el.style.display = 'none';
if (this._lock) unlockBody(); if (this._lock) unlockBody();
this._initialValues = {}; this._initialValues = {};
this.hideError(); this.hideError();
this.onForceClose(); this.onForceClose();
Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack = Modal._stack.filter(m => m !== this);
if (this._previousFocus && typeof this._previousFocus.focus === 'function') {
this._previousFocus.focus();
this._previousFocus = null;
}
} }
async close() { async close() {

View File

@@ -41,7 +41,11 @@ export const EDGE_TEST_COLORS = {
export const loggedErrors = new Map(); export const loggedErrors = new Map();
// Device brightness cache // Device brightness cache
export const _deviceBrightnessCache = {}; export let _deviceBrightnessCache = {};
export function set_deviceBrightnessCache(v) { _deviceBrightnessCache = v; }
export function updateDeviceBrightness(deviceId, value) {
_deviceBrightnessCache = { ..._deviceBrightnessCache, [deviceId]: value };
}
// Discovery state // Discovery state
export let _discoveryScanRunning = false; export let _discoveryScanRunning = false;

View File

@@ -11,6 +11,32 @@ export function toggleHint(btn) {
const visible = hint.style.display !== 'none'; const visible = hint.style.display !== 'none';
hint.style.display = visible ? 'none' : 'block'; hint.style.display = visible ? 'none' : 'block';
btn.classList.toggle('active', !visible); btn.classList.toggle('active', !visible);
btn.setAttribute('aria-expanded', String(!visible));
}
}
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function trapFocus(modal) {
modal._trapHandler = (e) => {
if (e.key !== 'Tab') return;
const focusable = [...modal.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
modal.addEventListener('keydown', modal._trapHandler);
}
export function releaseFocus(modal) {
if (modal._trapHandler) {
modal.removeEventListener('keydown', modal._trapHandler);
modal._trapHandler = null;
} }
} }
@@ -111,7 +137,7 @@ export function showConfirm(message, title = null) {
setConfirmResolve(resolve); setConfirmResolve(resolve);
const modal = document.getElementById('confirm-modal'); const modal = document.getElementById('confirm-modal');
const titleEl = document.getElementById('confirm-title'); const titleEl = document.getElementById('confirm-modal-title');
const messageEl = document.getElementById('confirm-message'); const messageEl = document.getElementById('confirm-message');
const yesBtn = document.getElementById('confirm-yes-btn'); const yesBtn = document.getElementById('confirm-yes-btn');
const noBtn = document.getElementById('confirm-no-btn'); const noBtn = document.getElementById('confirm-no-btn');
@@ -123,11 +149,13 @@ export function showConfirm(message, title = null) {
modal.style.display = 'flex'; modal.style.display = 'flex';
lockBody(); lockBody();
trapFocus(modal);
}); });
} }
export function closeConfirmModal(result) { export function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal'); const modal = document.getElementById('confirm-modal');
releaseFocus(modal);
modal.style.display = 'none'; modal.style.display = 'none';
unlockBody(); unlockBody();

View File

@@ -5,7 +5,7 @@
import { import {
calibrationTestState, EDGE_TEST_COLORS, calibrationTestState, EDGE_TEST_COLORS,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
@@ -53,11 +53,10 @@ let _previewRaf = null;
export async function showCalibration(deviceId) { export async function showCalibration(deviceId) {
try { try {
const [response, displaysResponse] = await Promise.all([ const [response, displaysResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetchWithAuth(`/devices/${deviceId}`),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), fetchWithAuth('/config/displays'),
]); ]);
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) { showToast('Failed to load calibration', 'error'); return; } if (!response.ok) { showToast('Failed to load calibration', 'error'); return; }
const device = await response.json(); const device = await response.json();
@@ -131,6 +130,7 @@ export async function showCalibration(deviceId) {
window._calibrationResizeObserver.observe(preview); window._calibrationResizeObserver.observe(preview);
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Failed to load calibration:', error); console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error'); showToast('Failed to load calibration', 'error');
} }
@@ -626,18 +626,17 @@ export async function toggleTestEdge(edge) {
updateCalibrationPreview(); updateCalibrationPreview();
try { try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges }) body: JSON.stringify({ edges })
}); });
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`; error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return;
console.error('Failed to toggle test edge:', err); console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge'; error.textContent = 'Failed to toggle test edge';
error.style.display = 'block'; error.style.display = 'block';
@@ -696,12 +695,10 @@ export async function saveCalibration() {
}; };
try { try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration`, { const response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(calibration) body: JSON.stringify(calibration)
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast('Calibration saved', 'success'); showToast('Calibration saved', 'success');
calibModal.forceClose(); calibModal.forceClose();
@@ -712,6 +709,7 @@ export async function saveCalibration() {
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return;
console.error('Failed to save calibration:', err); console.error('Failed to save calibration:', err);
error.textContent = 'Failed to save calibration'; error.textContent = 'Failed to save calibration';
error.style.display = 'block'; error.style.display = 'block';

View File

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

View File

@@ -6,7 +6,7 @@ import {
_discoveryScanRunning, set_discoveryScanRunning, _discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, isSerialDevice, escapeHtml, handle401Error } from '../core/api.js'; import { API_BASE, fetchWithAuth, isSerialDevice, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -214,11 +214,7 @@ export async function scanForDevices(forceType) {
if (scanBtn) scanBtn.disabled = true; if (scanBtn) scanBtn.disabled = true;
try { try {
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, { const response = await fetchWithAuth(`/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`);
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
loading.style.display = 'none'; loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
@@ -240,6 +236,7 @@ export async function scanForDevices(forceType) {
_renderDiscoveryList(); _renderDiscoveryList();
} }
} catch (err) { } catch (err) {
if (err.isAuth) return;
loading.style.display = 'none'; loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
if (!isSerialDevice(scanType)) { if (!isSerialDevice(scanType)) {
@@ -298,17 +295,11 @@ export async function handleAddDevice(event) {
body.capture_template_id = lastTemplateId; body.capture_template_id = lastTemplateId;
} }
const response = await fetch(`${API_BASE}/devices`, { const response = await fetchWithAuth('/devices', {
method: 'POST', method: 'POST',
headers: getHeaders(),
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (response.status === 401) {
handle401Error();
return;
}
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
console.log('Device added successfully:', result); console.log('Device added successfully:', result);
@@ -330,6 +321,7 @@ export async function handleAddDevice(event) {
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
if (err.isAuth) return;
console.error('Failed to add device:', err); console.error('Failed to add device:', err);
showToast('Failed to add device', 'error'); showToast('Failed to add device', 'error');
} }

View File

@@ -3,11 +3,11 @@
*/ */
import { import {
_deviceBrightnessCache, _deviceBrightnessCache, updateDeviceBrightness,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
class DeviceSettingsModal extends Modal { class DeviceSettingsModal extends Modal {
@@ -110,18 +110,15 @@ export function createDeviceCard(device) {
export async function toggleDevicePower(deviceId) { export async function toggleDevicePower(deviceId) {
try { try {
const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() }); const getResp = await fetchWithAuth(`/devices/${deviceId}/power`);
if (getResp.status === 401) { handle401Error(); return; }
if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; } if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; }
const current = await getResp.json(); const current = await getResp.json();
const newState = !current.on; const newState = !current.on;
const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
method: 'PUT', method: 'PUT',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ on: newState }) body: JSON.stringify({ on: newState })
}); });
if (setResp.status === 401) { handle401Error(); return; }
if (setResp.ok) { if (setResp.ok) {
showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success'); showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success');
} else { } else {
@@ -129,6 +126,7 @@ export async function toggleDevicePower(deviceId) {
showToast(error.detail || 'Failed', 'error'); showToast(error.detail || 'Failed', 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to toggle power', 'error'); showToast('Failed to toggle power', 'error');
} }
} }
@@ -142,11 +140,9 @@ export async function removeDevice(deviceId) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetch(`${API_BASE}/devices/${deviceId}`, { const response = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast('Device removed', 'success'); showToast('Device removed', 'success');
window.loadDevices(); window.loadDevices();
@@ -155,6 +151,7 @@ export async function removeDevice(deviceId) {
showToast(`Failed to remove: ${error.detail}`, 'error'); showToast(`Failed to remove: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Failed to remove device:', error); console.error('Failed to remove device:', error);
showToast('Failed to remove device', 'error'); showToast('Failed to remove device', 'error');
} }
@@ -162,8 +159,7 @@ export async function removeDevice(deviceId) {
export async function showSettings(deviceId) { export async function showSettings(deviceId) {
try { try {
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }); const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
if (deviceResponse.status === 401) { handle401Error(); return; }
if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; } if (!deviceResponse.ok) { showToast('Failed to load device settings', 'error'); return; }
const device = await deviceResponse.json(); const device = await deviceResponse.json();
@@ -222,6 +218,7 @@ export async function showSettings(deviceId) {
}, 100); }, 100);
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Failed to load device settings:', error); console.error('Failed to load device settings:', error);
showToast('Failed to load device settings', 'error'); showToast('Failed to load device settings', 'error');
} }
@@ -251,14 +248,11 @@ export async function saveDeviceSettings() {
const baudVal = document.getElementById('settings-baud-rate').value; const baudVal = document.getElementById('settings-baud-rate').value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10); if (baudVal) body.baud_rate = parseInt(baudVal, 10);
} }
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (deviceResponse.status === 401) { handle401Error(); return; }
if (!deviceResponse.ok) { if (!deviceResponse.ok) {
const errorData = await deviceResponse.json(); const errorData = await deviceResponse.json();
settingsModal.showError(`Failed to update device: ${errorData.detail}`); settingsModal.showError(`Failed to update device: ${errorData.detail}`);
@@ -269,6 +263,7 @@ export async function saveDeviceSettings() {
settingsModal.forceClose(); settingsModal.forceClose();
window.loadDevices(); window.loadDevices();
} catch (err) { } catch (err) {
if (err.isAuth) return;
console.error('Failed to save device settings:', err); console.error('Failed to save device settings:', err);
settingsModal.showError('Failed to save settings'); settingsModal.showError('Failed to save settings');
} }
@@ -282,7 +277,7 @@ export function updateBrightnessLabel(deviceId, value) {
export async function saveCardBrightness(deviceId, value) { export async function saveCardBrightness(deviceId, value) {
const bri = parseInt(value); const bri = parseInt(value);
_deviceBrightnessCache[deviceId] = bri; updateDeviceBrightness(deviceId, bri);
try { try {
await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
method: 'PUT', method: 'PUT',
@@ -302,7 +297,7 @@ export async function fetchDeviceBrightness(deviceId) {
}); });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
_deviceBrightnessCache[deviceId] = data.brightness; updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`); const slider = document.querySelector(`[data-device-brightness="${deviceId}"]`);
if (slider) { if (slider) {
slider.value = data.brightness; slider.value = data.brightness;

View File

@@ -9,7 +9,7 @@ import {
kcWebSockets, kcWebSockets,
PATTERN_RECT_BORDERS, PATTERN_RECT_BORDERS,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { lockBody, showToast, showConfirm } from '../core/ui.js'; import { lockBody, showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -436,10 +436,12 @@ export function isKCEditorDirty() {
export async function closeKCEditorModal() { export async function closeKCEditorModal() {
await kcEditorModal.close(); await kcEditorModal.close();
set_kcNameManuallyEdited(false);
} }
export function forceCloseKCEditorModal() { export function forceCloseKCEditorModal() {
kcEditorModal.forceClose(); kcEditorModal.forceClose();
set_kcNameManuallyEdited(false);
} }
export async function saveKCEditor() { export async function saveKCEditor() {
@@ -475,22 +477,18 @@ export async function saveKCEditor() {
try { try {
let response; let response;
if (targetId) { if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.target_type = 'key_colors'; payload.target_type = 'key_colors';
response = await fetch(`${API_BASE}/picture-targets`, { response = await fetchWithAuth('/picture-targets', {
method: 'POST', method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await response.json();
throw new Error(err.detail || 'Failed to save'); throw new Error(err.detail || 'Failed to save');
@@ -501,6 +499,7 @@ export async function saveKCEditor() {
// Use window.* to avoid circular import with targets.js // Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Error saving KC target:', error); console.error('Error saving KC target:', error);
kcEditorModal.showError(error.message); kcEditorModal.showError(error.message);
} }
@@ -512,11 +511,9 @@ export async function deleteKCTarget(targetId) {
try { try {
disconnectKCWebSocket(targetId); disconnectKCWebSocket(targetId);
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('kc.deleted'), 'success'); showToast(t('kc.deleted'), 'success');
// Use window.* to avoid circular import with targets.js // Use window.* to avoid circular import with targets.js
@@ -526,6 +523,7 @@ export async function deleteKCTarget(targetId) {
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(`Failed to delete: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to delete key colors target', 'error'); showToast('Failed to delete key colors target', 'error');
} }
} }

View File

@@ -14,7 +14,7 @@ import {
PATTERN_RECT_COLORS, PATTERN_RECT_COLORS,
PATTERN_RECT_BORDERS, PATTERN_RECT_BORDERS,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -89,13 +89,13 @@ export async function showPatternTemplateEditor(templateId = null) {
document.getElementById('pattern-template-id').value = tmpl.id; document.getElementById('pattern-template-id').value = tmpl.id;
document.getElementById('pattern-template-name').value = tmpl.name; document.getElementById('pattern-template-name').value = tmpl.name;
document.getElementById('pattern-template-description').value = tmpl.description || ''; document.getElementById('pattern-template-description').value = tmpl.description || '';
document.getElementById('pattern-template-title').textContent = t('pattern.edit'); document.getElementById('pattern-template-modal-title').textContent = t('pattern.edit');
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
} else { } else {
document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-id').value = '';
document.getElementById('pattern-template-name').value = ''; document.getElementById('pattern-template-name').value = '';
document.getElementById('pattern-template-description').value = ''; document.getElementById('pattern-template-description').value = '';
document.getElementById('pattern-template-title').textContent = t('pattern.add'); document.getElementById('pattern-template-modal-title').textContent = t('pattern.add');
setPatternEditorRects([]); setPatternEditorRects([]);
} }
@@ -148,16 +148,15 @@ export async function savePatternTemplate() {
try { try {
let response; let response;
if (templateId) { if (templateId) {
response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload), method: 'PUT', body: JSON.stringify(payload),
}); });
} else { } else {
response = await fetch(`${API_BASE}/pattern-templates`, { response = await fetchWithAuth('/pattern-templates', {
method: 'POST', headers: getHeaders(), body: JSON.stringify(payload), method: 'POST', body: JSON.stringify(payload),
}); });
} }
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await response.json();
throw new Error(err.detail || 'Failed to save'); throw new Error(err.detail || 'Failed to save');
@@ -168,6 +167,7 @@ export async function savePatternTemplate() {
// Use window.* to avoid circular import with targets.js // Use window.* to avoid circular import with targets.js
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Error saving pattern template:', error); console.error('Error saving pattern template:', error);
patternModal.showError(error.message); patternModal.showError(error.message);
} }
@@ -178,10 +178,9 @@ export async function deletePatternTemplate(templateId) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'DELETE', headers: getHeaders(), method: 'DELETE',
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.status === 409) { if (response.status === 409) {
showToast(t('pattern.delete.referenced'), 'error'); showToast(t('pattern.delete.referenced'), 'error');
return; return;
@@ -194,6 +193,7 @@ export async function deletePatternTemplate(templateId) {
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(`Failed to delete: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to delete pattern template', 'error'); showToast('Failed to delete pattern template', 'error');
} }
} }

View File

@@ -3,7 +3,7 @@
*/ */
import { apiKey, _profilesCache, set_profilesCache } from '../core/state.js'; import { apiKey, _profilesCache, set_profilesCache } from '../core/state.js';
import { API_BASE, getHeaders, escapeHtml, handle401Error } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -25,12 +25,13 @@ export async function loadProfiles() {
if (!container) return; if (!container) return;
try { try {
const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() }); const resp = await fetchWithAuth('/profiles');
if (!resp.ok) throw new Error('Failed to load profiles'); if (!resp.ok) throw new Error('Failed to load profiles');
const data = await resp.json(); const data = await resp.json();
set_profilesCache(data.profiles); set_profilesCache(data.profiles);
renderProfiles(data.profiles); renderProfiles(data.profiles);
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Failed to load profiles:', error); console.error('Failed to load profiles:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`; container.innerHTML = `<p class="error-message">${error.message}</p>`;
} }
@@ -72,7 +73,7 @@ function createProfileCard(profile) {
} }
return `<span class="stream-card-prop">${c.condition_type}</span>`; return `<span class="stream-card-prop">${c.condition_type}</span>`;
}); });
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR '; const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or');
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`); condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
} }
@@ -96,7 +97,7 @@ function createProfileCard(profile) {
</div> </div>
</div> </div>
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta">${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}</span> <span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span>
<span class="card-meta">⚡ ${targetCountText}</span> <span class="card-meta">⚡ ${targetCountText}</span>
${lastActivityMeta} ${lastActivityMeta}
</div> </div>
@@ -128,7 +129,7 @@ export async function openProfileEditor(profileId) {
if (profileId) { if (profileId) {
titleEl.textContent = t('profiles.edit'); titleEl.textContent = t('profiles.edit');
try { try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() }); const resp = await fetchWithAuth(`/profiles/${profileId}`);
if (!resp.ok) throw new Error('Failed to load profile'); if (!resp.ok) throw new Error('Failed to load profile');
const profile = await resp.json(); const profile = await resp.json();
@@ -167,7 +168,7 @@ export function closeProfileEditorModal() {
async function loadProfileTargetChecklist(selectedIds) { async function loadProfileTargetChecklist(selectedIds) {
const container = document.getElementById('profile-targets-list'); const container = document.getElementById('profile-targets-list');
try { try {
const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); const resp = await fetchWithAuth('/picture-targets');
if (!resp.ok) throw new Error('Failed to load targets'); if (!resp.ok) throw new Error('Failed to load targets');
const data = await resp.json(); const data = await resp.json();
const targets = data.targets || []; const targets = data.targets || [];
@@ -253,8 +254,7 @@ async function toggleProcessPicker(picker, row) {
picker.style.display = ''; picker.style.display = '';
try { try {
const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() }); const resp = await fetchWithAuth('/system/processes');
if (resp.status === 401) { handle401Error(); return; }
if (!resp.ok) throw new Error('Failed to fetch processes'); if (!resp.ok) throw new Error('Failed to fetch processes');
const data = await resp.json(); const data = await resp.json();
@@ -326,7 +326,7 @@ export async function saveProfileEditor() {
const name = nameInput.value.trim(); const name = nameInput.value.trim();
if (!name) { if (!name) {
profileModal.showError('Name is required'); profileModal.showError(t('profiles.error.name_required'));
return; return;
} }
@@ -342,10 +342,9 @@ export async function saveProfileEditor() {
const isEdit = !!profileId; const isEdit = !!profileId;
try { try {
const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`; const url = isEdit ? `/profiles/${profileId}` : '/profiles';
const resp = await fetch(url, { const resp = await fetchWithAuth(url, {
method: isEdit ? 'PUT' : 'POST', method: isEdit ? 'PUT' : 'POST',
headers: getHeaders(),
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
@@ -354,9 +353,10 @@ export async function saveProfileEditor() {
} }
profileModal.forceClose(); profileModal.forceClose();
showToast(isEdit ? 'Profile updated' : 'Profile created', 'success'); showToast(isEdit ? t('profiles.updated') : t('profiles.created'), 'success');
loadProfiles(); loadProfiles();
} catch (e) { } catch (e) {
if (e.isAuth) return;
profileModal.showError(e.message); profileModal.showError(e.message);
} }
} }
@@ -364,13 +364,13 @@ export async function saveProfileEditor() {
export async function toggleProfileEnabled(profileId, enable) { export async function toggleProfileEnabled(profileId, enable) {
try { try {
const action = enable ? 'enable' : 'disable'; const action = enable ? 'enable' : 'disable';
const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, { const resp = await fetchWithAuth(`/profiles/${profileId}/${action}`, {
method: 'POST', method: 'POST',
headers: getHeaders(),
}); });
if (!resp.ok) throw new Error(`Failed to ${action} profile`); if (!resp.ok) throw new Error(`Failed to ${action} profile`);
loadProfiles(); loadProfiles();
} catch (e) { } catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
@@ -381,14 +381,14 @@ export async function deleteProfile(profileId, profileName) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { const resp = await fetchWithAuth(`/profiles/${profileId}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders(),
}); });
if (!resp.ok) throw new Error('Failed to delete profile'); if (!resp.ok) throw new Error('Failed to delete profile');
showToast('Profile deleted', 'success'); showToast(t('profiles.deleted'), 'success');
loadProfiles(); loadProfiles();
} catch (e) { } catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }

View File

@@ -20,7 +20,7 @@ import {
_lastValidatedImageSource, set_lastValidatedImageSource, _lastValidatedImageSource, set_lastValidatedImageSource,
apiKey, apiKey,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
@@ -105,6 +105,7 @@ export async function editTemplate(templateId) {
export function closeTemplateModal() { export function closeTemplateModal() {
templateModal.forceClose(); templateModal.forceClose();
setCurrentEditingTemplateId(null); setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
} }
function updateCaptureDuration(value) { function updateCaptureDuration(value) {
@@ -900,6 +901,7 @@ export async function deleteStream(streamId) {
export function closeStreamModal() { export function closeStreamModal() {
streamModal.forceClose(); streamModal.forceClose();
document.getElementById('stream-type').disabled = false; document.getElementById('stream-type').disabled = false;
set_streamNameManuallyEdited(false);
} }
async function validateStaticImage() { async function validateStaticImage() {
@@ -1375,6 +1377,7 @@ export async function deletePPTemplate(templateId) {
export function closePPTemplateModal() { export function closePPTemplateModal() {
ppTemplateModal.forceClose(); ppTemplateModal.forceClose();
set_modalFilters([]); set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
} }
// Exported helpers used by other modules // Exported helpers used by other modules

View File

@@ -5,7 +5,11 @@
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js'; import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js';
export function switchTab(name) { export function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-selected', String(isActive));
});
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name); localStorage.setItem('activeTab', name);
if (name === 'dashboard') { if (name === 'dashboard') {

View File

@@ -8,7 +8,7 @@ import {
_deviceBrightnessCache, _deviceBrightnessCache,
kcWebSockets, kcWebSockets,
} from '../core/state.js'; } from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js'; import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
@@ -194,22 +194,18 @@ export async function saveTargetEditor() {
try { try {
let response; let response;
if (targetId) { if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.target_type = 'led'; payload.target_type = 'led';
response = await fetch(`${API_BASE}/picture-targets`, { response = await fetchWithAuth('/picture-targets', {
method: 'POST', method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await response.json();
throw new Error(err.detail || 'Failed to save'); throw new Error(err.detail || 'Failed to save');
@@ -219,6 +215,7 @@ export async function saveTargetEditor() {
targetEditorModal.forceClose(); targetEditorModal.forceClose();
await loadTargetsTab(); await loadTargetsTab();
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Error saving target:', error); console.error('Error saving target:', error);
targetEditorModal.showError(error.message); targetEditorModal.showError(error.message);
} }
@@ -248,17 +245,12 @@ export async function loadTargetsTab() {
try { try {
// Fetch devices, targets, sources, and pattern templates in parallel // Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([ const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }), fetchWithAuth('/devices'),
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }), fetchWithAuth('/picture-targets'),
fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null),
]); ]);
if (devicesResp.status === 401 || targetsResp.status === 401) {
handle401Error();
return;
}
const devicesData = await devicesResp.json(); const devicesData = await devicesResp.json();
const devices = devicesData.devices || []; const devices = devicesData.devices || [];
@@ -424,6 +416,7 @@ export async function loadTargetsTab() {
}); });
} catch (error) { } catch (error) {
if (error.isAuth) return;
console.error('Failed to load targets tab:', error); console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`; container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
} }
@@ -548,11 +541,9 @@ export function createTargetCard(target, deviceMap, sourceMap) {
export async function startTargetProcessing(targetId) { export async function startTargetProcessing(targetId) {
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
method: 'POST', method: 'POST',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('device.started'), 'success'); showToast(t('device.started'), 'success');
loadTargetsTab(); loadTargetsTab();
@@ -561,17 +552,16 @@ export async function startTargetProcessing(targetId) {
showToast(`Failed to start: ${error.detail}`, 'error'); showToast(`Failed to start: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to start processing', 'error'); showToast('Failed to start processing', 'error');
} }
} }
export async function stopTargetProcessing(targetId) { export async function stopTargetProcessing(targetId) {
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
method: 'POST', method: 'POST',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('device.stopped'), 'success'); showToast(t('device.stopped'), 'success');
loadTargetsTab(); loadTargetsTab();
@@ -580,17 +570,16 @@ export async function stopTargetProcessing(targetId) {
showToast(`Failed to stop: ${error.detail}`, 'error'); showToast(`Failed to stop: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to stop processing', 'error'); showToast('Failed to stop processing', 'error');
} }
} }
export async function startTargetOverlay(targetId) { export async function startTargetOverlay(targetId) {
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, {
method: 'POST', method: 'POST',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('overlay.started'), 'success'); showToast(t('overlay.started'), 'success');
loadTargetsTab(); loadTargetsTab();
@@ -599,17 +588,16 @@ export async function startTargetOverlay(targetId) {
showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast(t('overlay.error.start'), 'error'); showToast(t('overlay.error.start'), 'error');
} }
} }
export async function stopTargetOverlay(targetId) { export async function stopTargetOverlay(targetId) {
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, {
method: 'POST', method: 'POST',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('overlay.stopped'), 'success'); showToast(t('overlay.stopped'), 'success');
loadTargetsTab(); loadTargetsTab();
@@ -618,6 +606,7 @@ export async function stopTargetOverlay(targetId) {
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast(t('overlay.error.stop'), 'error'); showToast(t('overlay.error.stop'), 'error');
} }
} }
@@ -627,11 +616,9 @@ export async function deleteTarget(targetId) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, { const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders()
}); });
if (response.status === 401) { handle401Error(); return; }
if (response.ok) { if (response.ok) {
showToast(t('targets.deleted'), 'success'); showToast(t('targets.deleted'), 'success');
loadTargetsTab(); loadTargetsTab();
@@ -640,6 +627,7 @@ export async function deleteTarget(targetId) {
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(`Failed to delete: ${error.detail}`, 'error');
} }
} catch (error) { } catch (error) {
if (error.isAuth) return;
showToast('Failed to delete target', 'error'); showToast('Failed to delete target', 'error');
} }
} }

View File

@@ -512,5 +512,24 @@
"profiles.status.active": "Active", "profiles.status.active": "Active",
"profiles.status.inactive": "Inactive", "profiles.status.inactive": "Inactive",
"profiles.status.disabled": "Disabled", "profiles.status.disabled": "Disabled",
"profiles.last_activated": "Last activated" "profiles.last_activated": "Last activated",
"profiles.logic.and": " AND ",
"profiles.logic.or": " OR ",
"profiles.logic.all": "ALL",
"profiles.logic.any": "ANY",
"profiles.updated": "Profile updated",
"profiles.created": "Profile created",
"profiles.deleted": "Profile deleted",
"profiles.error.name_required": "Name is required",
"time.hours_minutes": "{h}h {m}m",
"time.minutes_seconds": "{m}m {s}s",
"time.seconds": "{s}s",
"dashboard.type.led": "LED",
"dashboard.type.kc": "Key Colors",
"aria.close": "Close",
"aria.save": "Save",
"aria.cancel": "Cancel",
"aria.previous": "Previous",
"aria.next": "Next",
"aria.hint": "Show hint"
} }

View File

@@ -512,5 +512,24 @@
"profiles.status.active": "Активен", "profiles.status.active": "Активен",
"profiles.status.inactive": "Неактивен", "profiles.status.inactive": "Неактивен",
"profiles.status.disabled": "Отключён", "profiles.status.disabled": "Отключён",
"profiles.last_activated": "Последняя активация" "profiles.last_activated": "Последняя активация",
"profiles.logic.and": " И ",
"profiles.logic.or": " ИЛИ ",
"profiles.logic.all": "ВСЕ",
"profiles.logic.any": "ЛЮБОЕ",
"profiles.updated": "Профиль обновлён",
"profiles.created": "Профиль создан",
"profiles.deleted": "Профиль удалён",
"profiles.error.name_required": "Введите название",
"time.hours_minutes": "{h}ч {m}м",
"time.minutes_seconds": "{m}м {s}с",
"time.seconds": "{s}с",
"dashboard.type.led": "LED",
"dashboard.type.kc": "Цвета клавиш",
"aria.close": "Закрыть",
"aria.save": "Сохранить",
"aria.cancel": "Отмена",
"aria.previous": "Назад",
"aria.next": "Вперёд",
"aria.hint": "Показать подсказку"
} }

View File

@@ -45,32 +45,32 @@
</header> </header>
<div class="tabs"> <div class="tabs">
<div class="tab-bar"> <div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button> <button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')"><span data-i18n="profiles.title">📋 Profiles</span></button> <button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles"><span data-i18n="profiles.title">📋 Profiles</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets"><span data-i18n="targets.title">⚡ Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams"><span data-i18n="streams.title">📺 Sources</span></button>
</div> </div>
<div class="tab-panel" id="tab-dashboard"> <div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
<div id="dashboard-content"> <div id="dashboard-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-profiles"> <div class="tab-panel" id="tab-profiles" role="tabpanel" aria-labelledby="tab-btn-profiles">
<div id="profiles-content"> <div id="profiles-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-targets"> <div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
<div id="targets-panel-content"> <div id="targets-panel-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-streams"> <div class="tab-panel" id="tab-streams" role="tabpanel" aria-labelledby="tab-btn-streams">
<div id="streams-list"> <div id="streams-list">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>

View File

@@ -1,11 +1,11 @@
<!-- Add Device Modal --> <!-- Add Device Modal -->
<div id="add-device-modal" class="modal"> <div id="add-device-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="add-device-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="devices.add">Add New Device</h2> <h2 id="add-device-modal-title" data-i18n="devices.add">Add New Device</h2>
<div class="modal-header-actions"> <div class="modal-header-actions">
<button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery">&#x1F50D;</button> <button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery">&#x1F50D;</button>
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -82,8 +82,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAddDeviceModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeAddDeviceModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Login Modal --> <!-- Login Modal -->
<div id="api-key-modal" class="modal"> <div id="api-key-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="api-key-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="auth.title">🔑 Login to LED Grab</h2> <h2 id="api-key-modal-title" data-i18n="auth.title">🔑 Login to LED Grab</h2>
<button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" id="modal-close-x-btn" onclick="closeApiKeyModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<form id="api-key-form" onsubmit="submitApiKey(event)"> <form id="api-key-form" onsubmit="submitApiKey(event)">
<div class="modal-body"> <div class="modal-body">
@@ -32,8 +32,8 @@
<div id="api-key-error" class="error-message" style="display: none;"></div> <div id="api-key-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel">&#x2715;</button> <button type="button" class="btn btn-icon btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button type="submit" class="btn btn-icon btn-primary" title="Login">&#x2713;</button> <button type="submit" class="btn btn-icon btn-primary" title="Login" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,10 +1,10 @@
<!-- Calibration Modal --> <!-- Calibration Modal -->
<div id="calibration-modal" class="modal"> <div id="calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="calibration-modal-title">
<div class="modal-content" style="max-width: 700px;"> <div class="modal-content" style="max-width: 700px;">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="calibration.title">📐 LED Calibration</h2> <h2 id="calibration-modal-title" data-i18n="calibration.title">📐 LED Calibration</h2>
<button class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button> <button class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button>
<button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="calibration-device-id"> <input type="hidden" id="calibration-device-id">
@@ -137,8 +137,8 @@
<div id="calibration-error" class="error-message" style="display: none;"></div> <div id="calibration-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Template Modal --> <!-- Template Modal -->
<div id="template-modal" class="modal"> <div id="template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="template-modal-title" data-i18n="templates.add">Add Capture Template</h2> <h2 id="template-modal-title" data-i18n="templates.add">Add Capture Template</h2>
<button class="modal-close-btn" onclick="closeTemplateModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="template-id"> <input type="hidden" id="template-id">
@@ -38,8 +38,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeTemplateModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeTemplateModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveTemplate()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveTemplate()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Confirmation Modal --> <!-- Confirmation Modal -->
<div id="confirm-modal" class="modal"> <div id="confirm-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title">
<div class="modal-content" style="max-width: 450px;"> <div class="modal-content" style="max-width: 450px;">
<div class="modal-header"> <div class="modal-header">
<h2 id="confirm-title">Confirm Action</h2> <h2 id="confirm-modal-title">Confirm Action</h2>
<button class="modal-close-btn" onclick="closeConfirmModal(false)" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeConfirmModal(false)" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p id="confirm-message" class="modal-description"></p> <p id="confirm-message" class="modal-description"></p>

View File

@@ -1,9 +1,9 @@
<!-- General Settings Modal --> <!-- General Settings Modal -->
<div id="device-settings-modal" class="modal"> <div id="device-settings-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="settings.general.title">⚙️ General Settings</h2> <h2 id="device-settings-modal-title" data-i18n="settings.general.title">⚙️ General Settings</h2>
<button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeDeviceSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="device-settings-form"> <form id="device-settings-form">
@@ -83,8 +83,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeDeviceSettingsModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeDeviceSettingsModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveDeviceSettings()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveDeviceSettings()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Key Colors Editor Modal --> <!-- Key Colors Editor Modal -->
<div id="kc-editor-modal" class="modal"> <div id="kc-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="kc-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2> <h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="kc-editor-form"> <form id="kc-editor-form">
@@ -73,8 +73,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Pattern Template Editor Modal --> <!-- Pattern Template Editor Modal -->
<div id="pattern-template-modal" class="modal"> <div id="pattern-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pattern-template-modal-title">
<div class="modal-content modal-content-wide"> <div class="modal-content modal-content-wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="pattern-template-title" data-i18n="pattern.add">📄 Add Pattern Template</h2> <h2 id="pattern-template-modal-title" data-i18n="pattern.add">📄 Add Pattern Template</h2>
<button class="modal-close-btn" onclick="closePatternTemplateModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closePatternTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="pattern-template-form"> <form id="pattern-template-form">
@@ -60,8 +60,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closePatternTemplateModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closePatternTemplateModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePatternTemplate()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="savePatternTemplate()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Processing Template Modal --> <!-- Processing Template Modal -->
<div id="pp-template-modal" class="modal"> <div id="pp-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pp-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="pp-template-modal-title" data-i18n="postprocessing.add">Add Processing Template</h2> <h2 id="pp-template-modal-title" data-i18n="postprocessing.add">Add Processing Template</h2>
<button class="modal-close-btn" onclick="closePPTemplateModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closePPTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="pp-template-id"> <input type="hidden" id="pp-template-id">
@@ -33,8 +33,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closePPTemplateModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closePPTemplateModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePPTemplate()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="savePPTemplate()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Profile Editor Modal --> <!-- Profile Editor Modal -->
<div id="profile-editor-modal" class="modal"> <div id="profile-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2> <h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2>
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="profile-editor-form"> <form id="profile-editor-form">
@@ -67,8 +67,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeProfileEditorModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeProfileEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Source Modal --> <!-- Source Modal -->
<div id="stream-modal" class="modal"> <div id="stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="stream-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="stream-modal-title" data-i18n="streams.add">Add Source</h2> <h2 id="stream-modal-title" data-i18n="streams.add">Add Source</h2>
<button class="modal-close-btn" onclick="closeStreamModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeStreamModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="stream-id"> <input type="hidden" id="stream-id">
@@ -95,8 +95,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeStreamModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeStreamModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveStream()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveStream()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Target Editor Modal (name, device, source, settings) --> <!-- Target Editor Modal (name, device, source, settings) -->
<div id="target-editor-modal" class="modal"> <div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="target-editor-title" data-i18n="targets.add">🎯 Add Target</h2> <h2 id="target-editor-title" data-i18n="targets.add">🎯 Add Target</h2>
<button class="modal-close-btn" onclick="closeTargetEditorModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTargetEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="target-editor-form"> <form id="target-editor-form">
@@ -85,8 +85,8 @@
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeTargetEditorModal()" title="Cancel">&#x2715;</button> <button class="btn btn-icon btn-secondary" onclick="closeTargetEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveTargetEditor()" title="Save">&#x2713;</button> <button class="btn btn-icon btn-primary" onclick="saveTargetEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<!-- Test PP Template Modal --> <!-- Test PP Template Modal -->
<div id="test-pp-template-modal" class="modal"> <div id="test-pp-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-pp-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="postprocessing.test.title">Test Processing Template</h2> <h2 id="test-pp-template-modal-title" data-i18n="postprocessing.test.title">Test Processing Template</h2>
<button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">

View File

@@ -1,9 +1,9 @@
<!-- Test Source Modal --> <!-- Test Source Modal -->
<div id="test-stream-modal" class="modal"> <div id="test-stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-stream-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="streams.test.title">Test Source</h2> <h2 id="test-stream-modal-title" data-i18n="streams.test.title">Test Source</h2>
<button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">

View File

@@ -1,9 +1,9 @@
<!-- Test Template Modal --> <!-- Test Template Modal -->
<div id="test-template-modal" class="modal"> <div id="test-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="templates.test.title">Test Capture Template</h2> <h2 id="test-template-modal-title" data-i18n="templates.test.title">Test Capture Template</h2>
<button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">

View File

@@ -1,16 +1,16 @@
<!-- Device Tutorial Overlay (viewport-level) --> <!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed"> <div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed" role="dialog" aria-modal="true">
<div class="tutorial-backdrop"></div> <div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div> <div class="tutorial-ring"></div>
<div class="tutorial-tooltip"> <div class="tutorial-tooltip">
<div class="tutorial-tooltip-header"> <div class="tutorial-tooltip-header">
<span class="tutorial-step-counter"></span> <span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()">&times;</button> <button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div> </div>
<p class="tutorial-tooltip-text"></p> <p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav"> <div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()">&#8592;</button> <button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()">&#8594;</button> <button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">&#8594;</button>
</div> </div>
</div> </div>
</div> </div>