Split monolithic app.js into native ES modules

Replace the single 7034-line app.js with 17 ES module files organized
into core/ (state, api, i18n, ui) and features/ (calibration, dashboard,
device-discovery, devices, displays, kc-targets, pattern-templates,
profiles, streams, tabs, targets, tutorials) with an app.js entry point
that registers ~90 onclick globals on window. No bundler needed — FastAPI
serves modules directly via <script type="module">.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 17:15:00 +03:00
parent 3bac9c4ed9
commit fb1086b309
19 changed files with 7037 additions and 7041 deletions

View File

@@ -0,0 +1,632 @@
/**
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
*/
import {
targetEditorInitialValues, setTargetEditorInitialValues,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
import { t } from '../core/i18n.js';
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
function _updateStandbyVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const standbyGroup = document.getElementById('target-editor-standby-group');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const caps = selectedDevice?.capabilities || [];
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
export async function showTargetEditor(targetId = null) {
try {
// Load devices and sources for dropdowns
const [devicesResp, sourcesResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
set_targetEditorDevices(devices);
// Populate device select
const deviceSelect = document.getElementById('target-editor-device');
deviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);
});
deviceSelect.onchange = _updateStandbyVisibility;
// Populate source select
const sourceSelect = document.getElementById('target-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
sourceSelect.value = target.picture_source_id || '';
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
deviceSelect.value = '';
sourceSelect.value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
}
// Show/hide standby interval based on selected device capabilities
_updateStandbyVisibility();
setTargetEditorInitialValues({
name: document.getElementById('target-editor-name').value,
device: deviceSelect.value,
source: sourceSelect.value,
fps: document.getElementById('target-editor-fps').value,
interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
});
const modal = document.getElementById('target-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeTargetEditorModal);
document.getElementById('target-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open target editor:', error);
showToast('Failed to open target editor', 'error');
}
}
export function isTargetEditorDirty() {
return (
document.getElementById('target-editor-name').value !== targetEditorInitialValues.name ||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
);
}
export async function closeTargetEditorModal() {
if (isTargetEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseTargetEditorModal();
}
export function forceCloseTargetEditorModal() {
document.getElementById('target-editor-modal').style.display = 'none';
document.getElementById('target-editor-error').style.display = 'none';
unlockBody();
setTargetEditorInitialValues({});
}
export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const sourceId = document.getElementById('target-editor-source').value;
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
const errorEl = document.getElementById('target-editor-error');
if (!name) {
errorEl.textContent = t('targets.error.name_required');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
device_id: deviceId,
picture_source_id: sourceId,
settings: {
fps: fps,
interpolation_mode: interpolation,
smoothing: smoothing,
standby_interval: standbyInterval,
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'led';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
forceCloseTargetEditorModal();
await loadTargetsTab();
} catch (error) {
console.error('Error saving target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// ===== TARGETS TAB (WLED devices + targets combined) =====
export async function loadTargets() {
// Alias for backward compatibility
await loadTargetsTab();
}
export function switchTargetSubTab(tabKey) {
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
);
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
}
export async function loadTargetsTab() {
const container = document.getElementById('targets-panel-content');
if (!container) return;
try {
// Fetch devices, targets, sources, and pattern templates in parallel
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
if (devicesResp.status === 401 || targetsResp.status === 401) {
handle401Error();
return;
}
const devicesData = await devicesResp.json();
const devices = devicesData.devices || [];
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let sourceMap = {};
if (sourcesResp && sourcesResp.ok) {
const srcData = await sourcesResp.json();
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
}
let patternTemplates = [];
let patternTemplateMap = {};
if (patResp && patResp.ok) {
const patData = await patResp.json();
patternTemplates = patData.templates || [];
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
}
// Fetch state for each device
const devicesWithState = await Promise.all(
devices.map(async (device) => {
try {
const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
return { ...device, state };
} catch {
return device;
}
})
);
// Fetch state + metrics for each target (+ colors for KC targets)
const targetsWithState = await Promise.all(
targets.map(async (target) => {
try {
const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
}
})
);
// Build device map for target name resolution
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
// Backward compat: map stored "wled" sub-tab to "led"
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid">
${ledDevices.map(device => createDeviceCard(device)).join('')}
<div class="template-card add-template-card" onclick="showAddDevice()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
<div class="templates-grid">
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + ledPanel + kcPanel;
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
// Only fetch from device if we don't have a cached value yet
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
if (slider) {
slider.value = bri;
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
}
}
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
} catch (error) {
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
}
}
export function createTargetCard(target, deviceMap, sourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const settings = target.settings || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const source = sourceMap[target.picture_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
let healthClass = 'health-unknown';
let healthTitle = '';
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return `
<div class="card" data-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${settings.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
</div>
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</div>
</div>
` : ''}
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
${state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
👁️
</button>
` : `
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
👁️
</button>
`}
</div>
</div>
`;
}
export async function startTargetProcessing(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.started'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to start processing', 'error');
}
}
export async function stopTargetProcessing(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('device.stopped'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to stop processing', 'error');
}
}
export async function startTargetOverlay(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('overlay.started'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
}
} catch (error) {
showToast(t('overlay.error.start'), 'error');
}
}
export async function stopTargetOverlay(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('overlay.stopped'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
}
} catch (error) {
showToast(t('overlay.error.stop'), 'error');
}
}
export async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('targets.deleted'), 'success');
loadTargetsTab();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete target', 'error');
}
}