From ed220a97e795ca7f50f58114a5fe1e9e193003d6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 18 Feb 2026 17:49:42 +0300 Subject: [PATCH] Extract Modal base class and fix target editor defaults Add core/modal.js with reusable Modal class that handles open/close, body locking, backdrop close, dirty checking, error display, and a static stack for ESC key management. Migrate all 13 modals across 8 feature files to use the base class, eliminating ~200 lines of duplicated boilerplate. Replace manual ESC handler list in app.js with Modal.closeTopmost(), fixing 3 modals that were previously unreachable via ESC. Remove 5 unused initialValues variables from state.js. Fix target editor to auto-select first device/source and auto-generate name like the KC editor does. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/static/js/app.js | 23 +--- .../wled_controller/static/js/core/modal.js | 93 +++++++++++++++ .../wled_controller/static/js/core/state.js | 15 --- .../static/js/features/calibration.js | 94 ++++++++------- .../static/js/features/device-discovery.js | 14 +-- .../static/js/features/devices.js | 108 +++++++----------- .../static/js/features/kc-targets.js | 70 +++++------- .../static/js/features/pattern-templates.js | 66 ++++++----- .../static/js/features/profiles.js | 20 ++-- .../static/js/features/streams.js | 70 +++++------- .../static/js/features/targets.js | 97 +++++++++------- 11 files changed, 341 insertions(+), 329 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/modal.js diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 02b6c0b..51e29b8 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -4,6 +4,7 @@ // Layer 0: state import { apiKey, setApiKey, refreshInterval } from './core/state.js'; +import { Modal } from './core/modal.js'; // Layer 1: api, i18n import { loadServerInfo, loadDisplays, configureApiKey } from './core/api.js'; @@ -271,31 +272,13 @@ Object.assign(window, { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { - // Close in order: overlay lightboxes first, then modals + // Close in order: overlay lightboxes first, then modals via stack if (document.getElementById('display-picker-lightbox').classList.contains('active')) { closeDisplayPicker(); } else if (document.getElementById('image-lightbox').classList.contains('active')) { closeLightbox(); } else { - const modals = [ - { id: 'test-pp-template-modal', close: closeTestPPTemplateModal }, - { id: 'test-stream-modal', close: closeTestStreamModal }, - { id: 'test-template-modal', close: closeTestTemplateModal }, - { id: 'stream-modal', close: closeStreamModal }, - { id: 'pp-template-modal', close: closePPTemplateModal }, - { id: 'template-modal', close: closeTemplateModal }, - { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal }, - { id: 'calibration-modal', close: forceCloseCalibrationModal }, - { id: 'target-editor-modal', close: forceCloseTargetEditorModal }, - { id: 'add-device-modal', close: closeAddDeviceModal }, - ]; - for (const m of modals) { - const el = document.getElementById(m.id); - if (el && el.style.display === 'flex') { - m.close(); - break; - } - } + Modal.closeTopmost(); } } }); diff --git a/server/src/wled_controller/static/js/core/modal.js b/server/src/wled_controller/static/js/core/modal.js new file mode 100644 index 0000000..0fe0179 --- /dev/null +++ b/server/src/wled_controller/static/js/core/modal.js @@ -0,0 +1,93 @@ +/** + * Modal base class — eliminates open/close/dirty-check boilerplate. + * + * Simple modals: const modal = new Modal('my-modal-id'); + * Dirty-check: class MyModal extends Modal { snapshotValues() { ... } } + */ + +import { t } from './i18n.js'; +import { lockBody, unlockBody, setupBackdropClose, showConfirm } from './ui.js'; + +export class Modal { + static _stack = []; + + constructor(elementId, { backdrop = true, lock = true } = {}) { + this.el = document.getElementById(elementId); + this.errorEl = this.el?.querySelector('.modal-error'); + this._lock = lock; + this._backdrop = backdrop; + this._initialValues = {}; + this._closing = false; + } + + get isOpen() { + return this.el?.style.display === 'flex'; + } + + open() { + this.el.style.display = 'flex'; + if (this._lock) lockBody(); + if (this._backdrop) setupBackdropClose(this.el, () => this.close()); + Modal._stack = Modal._stack.filter(m => m !== this); + Modal._stack.push(this); + } + + forceClose() { + this.el.style.display = 'none'; + if (this._lock) unlockBody(); + this._initialValues = {}; + this.hideError(); + this.onForceClose(); + Modal._stack = Modal._stack.filter(m => m !== this); + } + + async close() { + if (this._closing) return; + if (this.isDirty()) { + this._closing = true; + const confirmed = await showConfirm(t('modal.discard_changes')); + this._closing = false; + if (!confirmed) return; + } + this.forceClose(); + } + + /** Override in subclass to define tracked fields for dirty checking. */ + snapshotValues() { return {}; } + + /** Override if current values differ from snapshot format. */ + currentValues() { return this.snapshotValues(); } + + snapshot() { + this._initialValues = this.snapshotValues(); + } + + isDirty() { + const cur = this.currentValues(); + return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]); + } + + showError(msg) { + if (this.errorEl) { + this.errorEl.textContent = msg; + this.errorEl.style.display = 'block'; + } + } + + hideError() { + if (this.errorEl) this.errorEl.style.display = 'none'; + } + + /** Hook for subclass cleanup on force-close (canvas, observers, etc.). */ + onForceClose() {} + + $(id) { + return document.getElementById(id); + } + + static closeTopmost() { + const top = Modal._stack[Modal._stack.length - 1]; + if (top) { top.close(); return true; } + return false; + } +} diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index d07b084..17301bd 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -31,12 +31,6 @@ export let _displayPickerSelectedIndex = null; export function set_displayPickerSelectedIndex(v) { _displayPickerSelectedIndex = v; } // Calibration -export let settingsInitialValues = {}; -export function setSettingsInitialValues(v) { settingsInitialValues = v; } - -export let calibrationInitialValues = {}; -export function setCalibrationInitialValues(v) { calibrationInitialValues = v; } - export const calibrationTestState = {}; export const EDGE_TEST_COLORS = { @@ -105,16 +99,10 @@ export let _lastValidatedImageSource = ''; export function set_lastValidatedImageSource(v) { _lastValidatedImageSource = v; } // Target editor state -export let targetEditorInitialValues = {}; -export function setTargetEditorInitialValues(v) { targetEditorInitialValues = v; } - export let _targetEditorDevices = []; export function set_targetEditorDevices(v) { _targetEditorDevices = v; } // KC editor state -export let kcEditorInitialValues = {}; -export function setKcEditorInitialValues(v) { kcEditorInitialValues = v; } - export let _kcNameManuallyEdited = false; export function set_kcNameManuallyEdited(v) { _kcNameManuallyEdited = v; } @@ -143,9 +131,6 @@ export function setPatternEditorSelectedIdx(v) { patternEditorSelectedIdx = v; } export let patternEditorBgImage = null; export function setPatternEditorBgImage(v) { patternEditorBgImage = v; } -export let patternEditorInitialValues = {}; -export function setPatternEditorInitialValues(v) { patternEditorInitialValues = v; } - export let patternCanvasDragMode = null; export function setPatternCanvasDragMode(v) { patternCanvasDragMode = v; } diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js index 2ea41f2..49144c7 100644 --- a/server/src/wled_controller/static/js/features/calibration.js +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -3,14 +3,50 @@ */ import { - calibrationInitialValues, setCalibrationInitialValues, calibrationTestState, EDGE_TEST_COLORS, } from '../core/state.js'; import { API_BASE, getHeaders, handle401Error } from '../core/api.js'; -import { t } from '../core/i18n.js'; -import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; +import { showToast } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; import { closeTutorial, startCalibrationTutorial } from './tutorials.js'; +/* ── CalibrationModal subclass ────────────────────────────────── */ + +class CalibrationModal extends Modal { + constructor() { + super('calibration-modal'); + } + + snapshotValues() { + return { + start_position: this.$('cal-start-position').value, + layout: this.$('cal-layout').value, + offset: this.$('cal-offset').value, + top: this.$('cal-top-leds').value, + right: this.$('cal-right-leds').value, + bottom: this.$('cal-bottom-leds').value, + left: this.$('cal-left-leds').value, + spans: JSON.stringify(window.edgeSpans), + skip_start: this.$('cal-skip-start').value, + skip_end: this.$('cal-skip-end').value, + border_width: this.$('cal-border-width').value, + }; + } + + onForceClose() { + closeTutorial(); + const deviceId = this.$('calibration-device-id').value; + if (deviceId) clearTestMode(deviceId); + if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); + const error = this.$('calibration-error'); + if (error) error.style.display = 'none'; + } +} + +const calibModal = new CalibrationModal(); + +/* ── Public API (exported names unchanged) ────────────────────── */ + export async function showCalibration(deviceId) { try { const [response, displaysResponse] = await Promise.all([ @@ -63,27 +99,12 @@ export async function showCalibration(deviceId) { left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, }; - setCalibrationInitialValues({ - start_position: calibration.start_position, - layout: calibration.layout, - offset: String(calibration.offset || 0), - top: String(calibration.leds_top || 0), - right: String(calibration.leds_right || 0), - bottom: String(calibration.leds_bottom || 0), - left: String(calibration.leds_left || 0), - spans: JSON.stringify(window.edgeSpans), - skip_start: String(calibration.skip_leds_start || 0), - skip_end: String(calibration.skip_leds_end || 0), - border_width: String(calibration.border_width || 10), - }); - calibrationTestState[device.id] = new Set(); updateCalibrationPreview(); - const modal = document.getElementById('calibration-modal'); - modal.style.display = 'flex'; - lockBody(); + calibModal.snapshot(); + calibModal.open(); initSpanDrag(); requestAnimationFrame(() => { @@ -109,40 +130,15 @@ export async function showCalibration(deviceId) { } function isCalibrationDirty() { - return ( - document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position || - document.getElementById('cal-layout').value !== calibrationInitialValues.layout || - document.getElementById('cal-offset').value !== calibrationInitialValues.offset || - document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || - document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || - document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || - document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || - JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans || - document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start || - document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end || - document.getElementById('cal-border-width').value !== calibrationInitialValues.border_width - ); + return calibModal.isDirty(); } export function forceCloseCalibrationModal() { - closeTutorial(); - const deviceId = document.getElementById('calibration-device-id').value; - if (deviceId) clearTestMode(deviceId); - if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); - const modal = document.getElementById('calibration-modal'); - const error = document.getElementById('calibration-error'); - modal.style.display = 'none'; - error.style.display = 'none'; - unlockBody(); - setCalibrationInitialValues({}); + calibModal.forceClose(); } export async function closeCalibrationModal() { - if (isCalibrationDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseCalibrationModal(); + calibModal.close(); } export function updateOffsetSkipLock() { @@ -681,7 +677,7 @@ export async function saveCalibration() { if (response.status === 401) { handle401Error(); return; } if (response.ok) { showToast('Calibration saved', 'success'); - forceCloseCalibrationModal(); + calibModal.forceClose(); window.loadDevices(); } else { const errorData = await response.json(); diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index 2c9456d..a3db514 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -5,13 +5,15 @@ import { _discoveryScanRunning, set_discoveryScanRunning, _discoveryCache, set_discoveryCache, - settingsInitialValues, } from '../core/state.js'; import { API_BASE, getHeaders, isSerialDevice, escapeHtml, handle401Error } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { lockBody, unlockBody, showToast } from '../core/ui.js'; +import { showToast } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; import { _computeMaxFps, _renderFpsHint } from './devices.js'; +const addDeviceModal = new Modal('add-device-modal'); + export function onDeviceTypeChanged() { const deviceType = document.getElementById('device-type').value; const urlGroup = document.getElementById('device-url-group'); @@ -154,7 +156,6 @@ export function onSerialPortFocus() { } export function showAddDevice() { - const modal = document.getElementById('add-device-modal'); const form = document.getElementById('add-device-form'); const error = document.getElementById('add-device-error'); form.reset(); @@ -172,16 +173,13 @@ export function showAddDevice() { document.getElementById('device-serial-port').innerHTML = ''; const scanBtn = document.getElementById('scan-network-btn'); if (scanBtn) scanBtn.disabled = false; - modal.style.display = 'flex'; - lockBody(); + addDeviceModal.open(); onDeviceTypeChanged(); setTimeout(() => document.getElementById('device-name').focus(), 100); } export function closeAddDeviceModal() { - const modal = document.getElementById('add-device-modal'); - modal.style.display = 'none'; - unlockBody(); + addDeviceModal.forceClose(); } export async function scanForDevices(forceType) { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 2f40fad..18949bb 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -3,12 +3,38 @@ */ import { - settingsInitialValues, setSettingsInitialValues, _deviceBrightnessCache, } from '../core/state.js'; import { API_BASE, getHeaders, escapeHtml, isSerialDevice, handle401Error } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; +import { showToast } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; + +class DeviceSettingsModal extends Modal { + constructor() { super('device-settings-modal'); } + + deviceType = ''; + capabilities = []; + + snapshotValues() { + return { + name: this.$('settings-device-name').value, + url: this._getUrl(), + state_check_interval: this.$('settings-health-interval').value, + auto_shutdown: this.$('settings-auto-shutdown').checked, + led_count: this.$('settings-led-count').value, + }; + } + + _getUrl() { + if (isSerialDevice(this.deviceType)) { + return this.$('settings-serial-port').value; + } + return this.$('settings-device-url').value.trim(); + } +} + +const settingsModal = new DeviceSettingsModal(); export function createDeviceCard(device) { const state = device.state || {}; @@ -186,20 +212,10 @@ export async function showSettings(deviceId) { document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown; - setSettingsInitialValues({ - name: device.name, - url: device.url, - led_count: String(device.led_count || ''), - baud_rate: String(device.baud_rate || '115200'), - device_type: device.device_type, - capabilities: caps, - state_check_interval: '30', - auto_shutdown: !!device.auto_shutdown, - }); - - const modal = document.getElementById('device-settings-modal'); - modal.style.display = 'flex'; - lockBody(); + settingsModal.deviceType = device.device_type; + settingsModal.capabilities = caps; + settingsModal.snapshot(); + settingsModal.open(); setTimeout(() => { document.getElementById('settings-device-name').focus(); @@ -211,61 +227,27 @@ export async function showSettings(deviceId) { } } -function _getSettingsUrl() { - if (isSerialDevice(settingsInitialValues.device_type)) { - return document.getElementById('settings-serial-port').value; - } - return document.getElementById('settings-device-url').value.trim(); -} - -export function isSettingsDirty() { - const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count') - && document.getElementById('settings-led-count').value !== settingsInitialValues.led_count; - return ( - document.getElementById('settings-device-name').value !== settingsInitialValues.name || - _getSettingsUrl() !== settingsInitialValues.url || - document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval || - document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown || - ledCountDirty - ); -} - -export function forceCloseDeviceSettingsModal() { - const modal = document.getElementById('device-settings-modal'); - const error = document.getElementById('settings-error'); - modal.style.display = 'none'; - error.style.display = 'none'; - unlockBody(); - setSettingsInitialValues({}); -} - -export async function closeDeviceSettingsModal() { - if (isSettingsDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseDeviceSettingsModal(); -} +export function isSettingsDirty() { return settingsModal.isDirty(); } +export function forceCloseDeviceSettingsModal() { settingsModal.forceClose(); } +export function closeDeviceSettingsModal() { settingsModal.close(); } export async function saveDeviceSettings() { const deviceId = document.getElementById('settings-device-id').value; const name = document.getElementById('settings-device-name').value.trim(); - const url = _getSettingsUrl(); - const error = document.getElementById('settings-error'); + const url = settingsModal._getUrl(); if (!name || !url) { - error.textContent = 'Please fill in all fields correctly'; - error.style.display = 'block'; + settingsModal.showError('Please fill in all fields correctly'); return; } try { const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked }; const ledCountInput = document.getElementById('settings-led-count'); - if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) { + if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) { body.led_count = parseInt(ledCountInput.value, 10); } - if (isSerialDevice(settingsInitialValues.device_type)) { + if (isSerialDevice(settingsModal.deviceType)) { const baudVal = document.getElementById('settings-baud-rate').value; if (baudVal) body.baud_rate = parseInt(baudVal, 10); } @@ -279,18 +261,16 @@ export async function saveDeviceSettings() { if (!deviceResponse.ok) { const errorData = await deviceResponse.json(); - error.textContent = `Failed to update device: ${errorData.detail}`; - error.style.display = 'block'; + settingsModal.showError(`Failed to update device: ${errorData.detail}`); return; } showToast(t('settings.saved'), 'success'); - forceCloseDeviceSettingsModal(); + settingsModal.forceClose(); window.loadDevices(); } catch (err) { console.error('Failed to save device settings:', err); - error.textContent = 'Failed to save settings'; - error.style.display = 'block'; + settingsModal.showError('Failed to save settings'); } } @@ -406,7 +386,7 @@ export function updateSettingsBaudFpsHint() { const hintEl = document.getElementById('settings-baud-fps-hint'); const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); - _renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type); + _renderFpsHint(hintEl, baudRate, ledCount, settingsModal.deviceType); } // Settings serial port population (used from showSettings) @@ -419,7 +399,7 @@ async function _populateSettingsSerialPorts(currentUrl) { select.appendChild(loadingOpt); try { - const discoverType = settingsInitialValues.device_type || 'adalight'; + const discoverType = settingsModal.deviceType || 'adalight'; const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, { headers: getHeaders() }); diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 03ab549..1dd986c 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -5,14 +5,33 @@ import { kcTestAutoRefresh, setKcTestAutoRefresh, kcTestTargetId, setKcTestTargetId, - kcEditorInitialValues, setKcEditorInitialValues, _kcNameManuallyEdited, set_kcNameManuallyEdited, kcWebSockets, PATTERN_RECT_BORDERS, } 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 { lockBody, showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; + +class KCEditorModal extends Modal { + constructor() { + super('kc-editor-modal'); + } + + snapshotValues() { + return { + name: document.getElementById('kc-editor-name').value, + source: document.getElementById('kc-editor-source').value, + fps: document.getElementById('kc-editor-fps').value, + interpolation: document.getElementById('kc-editor-interpolation').value, + smoothing: document.getElementById('kc-editor-smoothing').value, + patternTemplateId: document.getElementById('kc-editor-pattern-template').value, + }; + } +} + +const kcEditorModal = new KCEditorModal(); export function createKCTargetCard(target, sourceMap, patternTemplateMap) { const state = target.state || {}; @@ -391,19 +410,8 @@ export async function showKCEditor(targetId = null) { patSelect.onchange = () => _autoGenerateKCName(); if (!targetId) _autoGenerateKCName(); - setKcEditorInitialValues({ - name: document.getElementById('kc-editor-name').value, - source: sourceSelect.value, - fps: document.getElementById('kc-editor-fps').value, - interpolation: document.getElementById('kc-editor-interpolation').value, - smoothing: document.getElementById('kc-editor-smoothing').value, - patternTemplateId: patSelect.value, - }); - - const modal = document.getElementById('kc-editor-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeKCEditorModal); + kcEditorModal.snapshot(); + kcEditorModal.open(); document.getElementById('kc-editor-error').style.display = 'none'; setTimeout(() => document.getElementById('kc-editor-name').focus(), 100); @@ -414,29 +422,15 @@ export async function showKCEditor(targetId = null) { } export function isKCEditorDirty() { - return ( - document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name || - document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source || - document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps || - document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation || - document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing || - document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId - ); + return kcEditorModal.isDirty(); } export async function closeKCEditorModal() { - if (isKCEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseKCEditorModal(); + await kcEditorModal.close(); } export function forceCloseKCEditorModal() { - document.getElementById('kc-editor-modal').style.display = 'none'; - document.getElementById('kc-editor-error').style.display = 'none'; - unlockBody(); - setKcEditorInitialValues({}); + kcEditorModal.forceClose(); } export async function saveKCEditor() { @@ -447,17 +441,14 @@ export async function saveKCEditor() { const interpolation = document.getElementById('kc-editor-interpolation').value; const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value); const patternTemplateId = document.getElementById('kc-editor-pattern-template').value; - const errorEl = document.getElementById('kc-editor-error'); if (!name) { - errorEl.textContent = t('kc.error.required'); - errorEl.style.display = 'block'; + kcEditorModal.showError(t('kc.error.required')); return; } if (!patternTemplateId) { - errorEl.textContent = t('kc.error.no_pattern'); - errorEl.style.display = 'block'; + kcEditorModal.showError(t('kc.error.no_pattern')); return; } @@ -497,13 +488,12 @@ export async function saveKCEditor() { } showToast(targetId ? t('kc.updated') : t('kc.created'), 'success'); - forceCloseKCEditorModal(); + kcEditorModal.forceClose(); // Use window.* to avoid circular import with targets.js if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } catch (error) { console.error('Error saving KC target:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; + kcEditorModal.showError(error.message); } } diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js index af9a99f..6dfbc0d 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.js +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -6,7 +6,6 @@ import { patternEditorRects, setPatternEditorRects, patternEditorSelectedIdx, setPatternEditorSelectedIdx, patternEditorBgImage, setPatternEditorBgImage, - patternEditorInitialValues, setPatternEditorInitialValues, patternCanvasDragMode, setPatternCanvasDragMode, patternCanvasDragStart, setPatternCanvasDragStart, patternCanvasDragOrigRect, setPatternCanvasDragOrigRect, @@ -17,7 +16,30 @@ import { } 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 { showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; + +class PatternTemplateModal extends Modal { + constructor() { + super('pattern-template-modal'); + } + + snapshotValues() { + return { + name: document.getElementById('pattern-template-name').value, + description: document.getElementById('pattern-template-description').value, + rectangles: JSON.stringify(patternEditorRects), + }; + } + + onForceClose() { + setPatternEditorRects([]); + setPatternEditorSelectedIdx(-1); + setPatternEditorBgImage(null); + } +} + +const patternModal = new PatternTemplateModal(); export function createPatternTemplateCard(pt) { const rectCount = (pt.rectangles || []).length; @@ -77,20 +99,13 @@ export async function showPatternTemplateEditor(templateId = null) { setPatternEditorRects([]); } - setPatternEditorInitialValues({ - name: document.getElementById('pattern-template-name').value, - description: document.getElementById('pattern-template-description').value, - rectangles: JSON.stringify(patternEditorRects), - }); + patternModal.snapshot(); renderPatternRectList(); renderPatternCanvas(); _attachPatternCanvasEvents(); - const modal = document.getElementById('pattern-template-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closePatternTemplateModal); + patternModal.open(); document.getElementById('pattern-template-error').style.display = 'none'; setTimeout(() => document.getElementById('pattern-template-name').focus(), 100); @@ -101,40 +116,24 @@ export async function showPatternTemplateEditor(templateId = null) { } export function isPatternEditorDirty() { - return ( - document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name || - document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description || - JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles - ); + return patternModal.isDirty(); } export async function closePatternTemplateModal() { - if (isPatternEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceClosePatternTemplateModal(); + await patternModal.close(); } export function forceClosePatternTemplateModal() { - document.getElementById('pattern-template-modal').style.display = 'none'; - document.getElementById('pattern-template-error').style.display = 'none'; - unlockBody(); - setPatternEditorRects([]); - setPatternEditorSelectedIdx(-1); - setPatternEditorBgImage(null); - setPatternEditorInitialValues({}); + patternModal.forceClose(); } export async function savePatternTemplate() { const templateId = document.getElementById('pattern-template-id').value; const name = document.getElementById('pattern-template-name').value.trim(); const description = document.getElementById('pattern-template-description').value.trim(); - const errorEl = document.getElementById('pattern-template-error'); if (!name) { - errorEl.textContent = t('pattern.error.required'); - errorEl.style.display = 'block'; + patternModal.showError(t('pattern.error.required')); return; } @@ -165,13 +164,12 @@ export async function savePatternTemplate() { } showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success'); - forceClosePatternTemplateModal(); + patternModal.forceClose(); // Use window.* to avoid circular import with targets.js if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } catch (error) { console.error('Error saving pattern template:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; + patternModal.showError(error.message); } } diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index ab58f74..b6d92ad 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -5,7 +5,10 @@ import { _profilesCache, set_profilesCache } from '../core/state.js'; import { API_BASE, getHeaders, escapeHtml, handle401Error } from '../core/api.js'; import { t, updateAllText } from '../core/i18n.js'; -import { lockBody, unlockBody, showToast, showConfirm } from '../core/ui.js'; +import { showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; + +const profileModal = new Modal('profile-editor-modal'); export async function loadProfiles() { const container = document.getElementById('profiles-content'); @@ -137,14 +140,12 @@ export async function openProfileEditor(profileId) { logicSelect.value = 'or'; } - modal.style.display = 'flex'; - lockBody(); + profileModal.open(); updateAllText(); } export function closeProfileEditorModal() { - document.getElementById('profile-editor-modal').style.display = 'none'; - unlockBody(); + profileModal.forceClose(); } async function loadProfileTargetChecklist(selectedIds) { @@ -304,12 +305,10 @@ export async function saveProfileEditor() { const nameInput = document.getElementById('profile-editor-name'); const enabledInput = document.getElementById('profile-editor-enabled'); const logicSelect = document.getElementById('profile-editor-logic'); - const errorEl = document.getElementById('profile-editor-error'); const name = nameInput.value.trim(); if (!name) { - errorEl.textContent = 'Name is required'; - errorEl.style.display = 'block'; + profileModal.showError('Name is required'); return; } @@ -336,12 +335,11 @@ export async function saveProfileEditor() { throw new Error(err.detail || 'Failed to save profile'); } - closeProfileEditorModal(); + profileModal.forceClose(); showToast(isEdit ? 'Profile updated' : 'Profile created', 'success'); loadProfiles(); } catch (e) { - errorEl.textContent = e.message; - errorEl.style.display = 'block'; + profileModal.showError(e.message); } } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 4bc4269..ed15797 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -21,9 +21,19 @@ import { } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js'; import { t } from '../core/i18n.js'; -import { setupBackdropClose, lockBody, unlockBody, showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; +import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; +// ===== Modal instances ===== + +const templateModal = new Modal('template-modal'); +const testTemplateModal = new Modal('test-template-modal'); +const streamModal = new Modal('stream-modal'); +const testStreamModal = new Modal('test-stream-modal'); +const ppTemplateModal = new Modal('pp-template-modal'); +const testPPTemplateModal = new Modal('test-pp-template-modal'); + // ===== Capture Templates ===== async function loadCaptureTemplates() { @@ -55,9 +65,7 @@ export async function showAddTemplateModal() { await loadAvailableEngines(); - const modal = document.getElementById('template-modal'); - modal.style.display = 'flex'; - setupBackdropClose(modal, closeTemplateModal); + templateModal.open(); } export async function editTemplate(templateId) { @@ -83,9 +91,7 @@ export async function editTemplate(templateId) { if (testResults) testResults.style.display = 'none'; document.getElementById('template-error').style.display = 'none'; - const modal = document.getElementById('template-modal'); - modal.style.display = 'flex'; - setupBackdropClose(modal, closeTemplateModal); + templateModal.open(); } catch (error) { console.error('Error loading template:', error); showToast(t('templates.error.load') + ': ' + error.message, 'error'); @@ -93,7 +99,7 @@ export async function editTemplate(templateId) { } export function closeTemplateModal() { - document.getElementById('template-modal').style.display = 'none'; + templateModal.forceClose(); setCurrentEditingTemplateId(null); } @@ -125,13 +131,11 @@ export async function showTestTemplateModal(templateId) { await loadDisplaysForTest(); restoreCaptureDuration(); - const modal = document.getElementById('test-template-modal'); - modal.style.display = 'flex'; - setupBackdropClose(modal, closeTestTemplateModal); + testTemplateModal.open(); } export function closeTestTemplateModal() { - document.getElementById('test-template-modal').style.display = 'none'; + testTemplateModal.forceClose(); window.currentTestingTemplate = null; } @@ -707,10 +711,7 @@ export async function showAddStreamModal(presetType) { await populateStreamModalDropdowns(); - const modal = document.getElementById('stream-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeStreamModal); + streamModal.open(); } export async function editStream(streamId) { @@ -754,10 +755,7 @@ export async function editStream(streamId) { if (stream.image_source) validateStaticImage(); } - const modal = document.getElementById('stream-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeStreamModal); + streamModal.open(); } catch (error) { console.error('Error loading stream:', error); showToast(t('streams.error.load') + ': ' + error.message, 'error'); @@ -896,9 +894,8 @@ export async function deleteStream(streamId) { } export function closeStreamModal() { - document.getElementById('stream-modal').style.display = 'none'; + streamModal.forceClose(); document.getElementById('stream-type').disabled = false; - unlockBody(); } async function validateStaticImage() { @@ -956,15 +953,11 @@ export async function showTestStreamModal(streamId) { set_currentTestStreamId(streamId); restoreStreamTestDuration(); - const modal = document.getElementById('test-stream-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeTestStreamModal); + testStreamModal.open(); } export function closeTestStreamModal() { - document.getElementById('test-stream-modal').style.display = 'none'; - unlockBody(); + testStreamModal.forceClose(); set_currentTestStreamId(null); } @@ -1032,15 +1025,11 @@ export async function showTestPPTemplateModal(templateId) { select.value = lastStream; } - const modal = document.getElementById('test-pp-template-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closeTestPPTemplateModal); + testPPTemplateModal.open(); } export function closeTestPPTemplateModal() { - document.getElementById('test-pp-template-modal').style.display = 'none'; - unlockBody(); + testPPTemplateModal.forceClose(); set_currentTestPPTemplateId(null); } @@ -1296,10 +1285,7 @@ export async function showAddPPTemplateModal() { _populateFilterSelect(); renderModalFilterList(); - const modal = document.getElementById('pp-template-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closePPTemplateModal); + ppTemplateModal.open(); } export async function editPPTemplate(templateId) { @@ -1324,10 +1310,7 @@ export async function editPPTemplate(templateId) { _populateFilterSelect(); renderModalFilterList(); - const modal = document.getElementById('pp-template-modal'); - modal.style.display = 'flex'; - lockBody(); - setupBackdropClose(modal, closePPTemplateModal); + ppTemplateModal.open(); } catch (error) { console.error('Error loading PP template:', error); showToast(t('postprocessing.error.load') + ': ' + error.message, 'error'); @@ -1386,9 +1369,8 @@ export async function deletePPTemplate(templateId) { } export function closePPTemplateModal() { - document.getElementById('pp-template-modal').style.display = 'none'; + ppTemplateModal.forceClose(); set_modalFilters([]); - unlockBody(); } // Exported helpers used by other modules diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 1f40d27..ef09e4f 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -3,20 +3,53 @@ */ 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 { showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.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) +class TargetEditorModal extends Modal { + constructor() { + super('target-editor-modal'); + } + + snapshotValues() { + return { + name: document.getElementById('target-editor-name').value, + device: document.getElementById('target-editor-device').value, + source: document.getElementById('target-editor-source').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 targetEditorModal = new TargetEditorModal(); + +let _targetNameManuallyEdited = false; + +function _autoGenerateTargetName() { + if (_targetNameManuallyEdited) return; + if (document.getElementById('target-editor-id').value) return; + const deviceSelect = document.getElementById('target-editor-device'); + const sourceSelect = document.getElementById('target-editor-source'); + const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; + const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || ''; + if (!deviceName || !sourceName) return; + document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${sourceName}`; +} + function _updateStandbyVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const standbyGroup = document.getElementById('target-editor-standby-group'); @@ -43,12 +76,12 @@ export async function showTargetEditor(targetId = null) { devices.forEach(d => { const opt = document.createElement('option'); opt.value = d.id; + opt.dataset.name = d.name; 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'); @@ -56,6 +89,7 @@ export async function showTargetEditor(targetId = null) { sources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; + opt.dataset.name = s.name; 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); @@ -80,11 +114,9 @@ export async function showTargetEditor(targetId = null) { 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 + // Creating new target — first option is selected by default 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'; @@ -95,23 +127,18 @@ export async function showTargetEditor(targetId = null) { document.getElementById('target-editor-title').textContent = t('targets.add'); } + // Auto-name generation + _targetNameManuallyEdited = !!targetId; + document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; + deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); }; + sourceSelect.onchange = () => _autoGenerateTargetName(); + if (!targetId) _autoGenerateTargetName(); + // 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); + targetEditorModal.snapshot(); + targetEditorModal.open(); document.getElementById('target-editor-error').style.display = 'none'; setTimeout(() => document.getElementById('target-editor-name').focus(), 100); @@ -122,30 +149,15 @@ export async function showTargetEditor(targetId = null) { } 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 - ); + return targetEditorModal.isDirty(); } export async function closeTargetEditorModal() { - if (isTargetEditorDirty()) { - const confirmed = await showConfirm(t('modal.discard_changes')); - if (!confirmed) return; - } - forceCloseTargetEditorModal(); + await targetEditorModal.close(); } export function forceCloseTargetEditorModal() { - document.getElementById('target-editor-modal').style.display = 'none'; - document.getElementById('target-editor-error').style.display = 'none'; - unlockBody(); - setTargetEditorInitialValues({}); + targetEditorModal.forceClose(); } export async function saveTargetEditor() { @@ -157,11 +169,9 @@ export async function saveTargetEditor() { 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'; + targetEditorModal.showError(t('targets.error.name_required')); return; } @@ -202,12 +212,11 @@ export async function saveTargetEditor() { } showToast(targetId ? t('targets.updated') : t('targets.created'), 'success'); - forceCloseTargetEditorModal(); + targetEditorModal.forceClose(); await loadTargetsTab(); } catch (error) { console.error('Error saving target:', error); - errorEl.textContent = error.message; - errorEl.style.display = 'block'; + targetEditorModal.showError(error.message); } }