From 122e95545ca63ccf15f50dc534a6a0b07a67560d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 01:21:27 +0300 Subject: [PATCH] Card bulk operations, remove expand/collapse, graph color picker fix - Bulk selection mode: Ctrl+Click or toggle button to enter, Escape to exit - Shift+Click for range select, bottom toolbar with SVG icon action buttons - All CardSections wired with bulk actions: Delete everywhere, Start/Stop for targets, Enable/Disable for automations - Remove expand/collapse all buttons (no collapsible sections remain) - Fix graph node color picker overlay persisting after outside click - Add Icons section to frontend.md conventions - Add trash2, listChecks, circleOff icons to icon system - Backend: processing loop performance improvements (monotonic timestamps, deque-based FPS tracking) Co-Authored-By: Claude Opus 4.6 (1M context) --- contexts/frontend.md | 18 ++ .../core/processing/wled_target_processor.py | 14 +- .../static/css/automations.css | 13 +- .../src/wled_controller/static/css/cards.css | 148 ++++++++- server/src/wled_controller/static/js/app.js | 7 - .../static/js/core/bulk-toolbar.js | 115 +++++++ .../static/js/core/card-sections.js | 302 ++++++++++++++---- .../static/js/core/color-picker.js | 3 + .../static/js/core/icon-paths.js | 3 + .../wled_controller/static/js/core/icons.js | 3 + .../static/js/features/automations.js | 53 ++- .../static/js/features/color-strips.js | 8 +- .../static/js/features/scene-presets.js | 15 +- .../static/js/features/streams.js | 84 +++-- .../static/js/features/targets.js | 84 ++++- .../wled_controller/static/locales/en.json | 16 +- .../wled_controller/static/locales/ru.json | 18 +- .../wled_controller/static/locales/zh.json | 16 +- 18 files changed, 771 insertions(+), 149 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/bulk-toolbar.js diff --git a/contexts/frontend.md b/contexts/frontend.md index 2ca8e60..219273a 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -151,6 +151,24 @@ The app has an interactive tutorial system (`static/js/features/tutorials.js`) w When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`). +## Icons + +**Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.** + +- Icon SVG paths are defined in `static/js/core/icon-paths.js` (Lucide icons, 24×24 viewBox) +- Icon constants are exported from `static/js/core/icons.js` (e.g. `ICON_START`, `ICON_TRASH`, `ICON_EDIT`) +- Use `_svg(path)` wrapper from `icons.js` to create new icon constants from paths + +When you need a new icon: +1. Find the Lucide icon at https://lucide.dev +2. Copy the inner SVG elements (paths, circles, rects) into `icon-paths.js` as a new export +3. Add a corresponding `ICON_*` constant in `icons.js` using `_svg(P.myIcon)` +4. Import and use the constant in your feature module + +Common icons: `ICON_START` (play), `ICON_STOP` (power), `ICON_EDIT` (pencil), `ICON_CLONE` (copy), `ICON_TRASH` (trash), `ICON_SETTINGS` (gear), `ICON_TEST` (flask), `ICON_OK` (circle-check), `ICON_WARNING` (triangle-alert), `ICON_HELP` (circle-help), `ICON_LIST_CHECKS` (list-checks), `ICON_CIRCLE_OFF` (circle-off). + +For icon-only buttons, use `btn btn-icon` CSS classes. The `.icon` class inside buttons auto-sizes to 16×16. + ## Localization (i18n) **Every user-facing string must be localized.** Never use hardcoded English strings in `showToast()`, `error.textContent`, modal messages, or any other UI-visible text. Always use `t('key')` from `../core/i18n.js` and add the corresponding key to **all three** locale files (`en.json`, `ru.json`, `zh.json`). diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 3aca515..e3d4aa1 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -595,7 +595,13 @@ class WledTargetProcessor(TargetProcessor): fps_samples: collections.deque = collections.deque(maxlen=10) _fps_sum = 0.0 - send_timestamps: collections.deque = collections.deque(maxlen=target_fps + 10) + send_timestamps: collections.deque = collections.deque(maxlen=self._target_fps + 10) + + def _fps_current_from_timestamps(): + """Count timestamps within the last second.""" + cutoff = time.perf_counter() - 1.0 + return sum(1 for ts in send_timestamps if ts > cutoff) + last_send_time = 0.0 _last_preview_broadcast = 0.0 prev_frame_time_stamp = time.perf_counter() @@ -839,7 +845,7 @@ class WledTargetProcessor(TargetProcessor): send_timestamps.append(now) self._metrics.frames_keepalive += 1 self._metrics.frames_skipped += 1 - self._metrics.fps_current = len(send_timestamps) + self._metrics.fps_current = _fps_current_from_timestamps() await asyncio.sleep(SKIP_REPOLL) continue @@ -864,7 +870,7 @@ class WledTargetProcessor(TargetProcessor): await self._broadcast_led_preview(send_colors, cur_brightness) _last_preview_broadcast = now self._metrics.frames_skipped += 1 - self._metrics.fps_current = len(send_timestamps) + self._metrics.fps_current = _fps_current_from_timestamps() is_animated = stream.is_animated repoll = SKIP_REPOLL if is_animated else frame_time await asyncio.sleep(repoll) @@ -923,7 +929,7 @@ class WledTargetProcessor(TargetProcessor): processing_time = now - loop_start self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 - self._metrics.fps_current = len(send_timestamps) + self._metrics.fps_current = _fps_current_from_timestamps() except Exception as e: self._metrics.errors_count += 1 diff --git a/server/src/wled_controller/static/css/automations.css b/server/src/wled_controller/static/css/automations.css index a80282b..5bc5884 100644 --- a/server/src/wled_controller/static/css/automations.css +++ b/server/src/wled_controller/static/css/automations.css @@ -27,9 +27,18 @@ padding: 0 4px; } -/* Automation condition pills need more room than the default 180px */ +/* Automation condition pills — constrain to card width */ +[data-automation-id] .card-meta { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 0; +} [data-automation-id] .stream-card-prop { - max-width: 280px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* Automation condition editor rows */ diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 022065e..d449694 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -267,8 +267,9 @@ body.cs-drag-active .card-drag-handle { opacity: 0 !important; } -/* Hide drag handles when filter is active */ -.cs-filtering .card-drag-handle { +/* Hide drag handles when filter is active or bulk selecting */ +.cs-filtering .card-drag-handle, +.cs-selecting .card-drag-handle { display: none; } @@ -1112,3 +1113,146 @@ ul.section-tip li { .led-preview-layers:hover .led-preview-layer-label { opacity: 1; } + +/* ── Bulk selection ────────────────────────────────────────── */ + +/* Toggle button in section header */ +.cs-bulk-toggle { + background: none; + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.75rem; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s, border-color 0.2s; + flex-shrink: 0; + line-height: 1; +} + +.cs-bulk-toggle:hover { + border-color: var(--primary-color); + color: var(--primary-text-color); +} + +.cs-bulk-toggle.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast, #fff); +} + +/* Checkbox inside card — hidden unless selecting */ +.card-bulk-check { + display: none; + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary-color); + flex-shrink: 0; +} + +.cs-selecting .card-bulk-check { + display: block; +} + +/* Selected card highlight */ +.cs-selecting .card-selected, +.cs-selecting .card-selected.template-card { + border-color: var(--primary-color); + box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent); +} + +/* Make cards visually clickable in selection mode */ +.cs-selecting .card, +.cs-selecting .template-card { + cursor: pointer; +} + +/* Suppress hover lift during selection */ +.cs-selecting .card:hover, +.cs-selecting .template-card:hover { + transform: none; +} + +/* ── Bulk toolbar ──────────────────────────────────────────── */ + +#bulk-toolbar { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(calc(100% + 30px)); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md, 8px); + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; + z-index: 1000; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3); + transition: transform 0.25s ease; + white-space: nowrap; +} + +#bulk-toolbar.visible { + transform: translateX(-50%) translateY(0); +} + +.bulk-select-all-wrap { + display: flex; + align-items: center; + cursor: pointer; +} + +.bulk-select-all-cb { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--primary-color); + cursor: pointer; +} + +.bulk-count { + font-size: 0.85rem; + color: var(--text-secondary); + min-width: 80px; +} + +.bulk-actions { + display: flex; + gap: 4px; +} + +.bulk-action-btn { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.bulk-action-btn .icon { + width: 16px; + height: 16px; +} + +.bulk-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; + line-height: 1; +} + +.bulk-close:hover { + color: var(--text-color); +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 4222648..1833822 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -68,7 +68,6 @@ import { showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT, csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption, renderCSPTModalFilterList, - expandAllStreamSections, collapseAllStreamSections, } from './features/streams.js'; import { createKCTargetCard, testKCTarget, @@ -90,7 +89,6 @@ import { loadAutomations, openAutomationEditor, closeAutomationEditorModal, saveAutomationEditor, addAutomationCondition, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, - expandAllAutomationSections, collapseAllAutomationSections, } from './features/automations.js'; import { openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, @@ -111,7 +109,6 @@ import { stopAllLedTargets, stopAllKCTargets, startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, toggleLedPreview, - expandAllTargetSections, collapseAllTargetSections, disconnectAllLedPreviewWS, } from './features/targets.js'; @@ -275,7 +272,6 @@ Object.assign(window, { // streams / capture templates / PP templates loadPictureSources, switchStreamTab, - expandAllStreamSections, collapseAllStreamSections, showAddTemplateModal, editTemplate, closeTemplateModal, @@ -377,8 +373,6 @@ Object.assign(window, { cloneAutomation, deleteAutomation, copyWebhookUrl, - expandAllAutomationSections, - collapseAllAutomationSections, // scene presets openScenePresetCapture, @@ -404,7 +398,6 @@ Object.assign(window, { // targets loadTargetsTab, switchTargetSubTab, - expandAllTargetSections, collapseAllTargetSections, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, diff --git a/server/src/wled_controller/static/js/core/bulk-toolbar.js b/server/src/wled_controller/static/js/core/bulk-toolbar.js new file mode 100644 index 0000000..91937ec --- /dev/null +++ b/server/src/wled_controller/static/js/core/bulk-toolbar.js @@ -0,0 +1,115 @@ +/** + * BulkToolbar — fixed-bottom action bar for card bulk operations. + * + * Singleton toolbar: only one section can be in bulk mode at a time. + * Renders dynamically based on the active section's configured actions. + * + * Each bulk action descriptor: + * { key, labelKey, icon?, style?, confirm?, handler } + * - icon: SVG HTML string (from icons.js) — rendered inside the button + */ + +import { t } from './i18n.js'; +import { showConfirm } from './ui.js'; + +let _activeSection = null; // CardSection currently in bulk mode +let _toolbarEl = null; // cached DOM element + +function _ensureEl() { + if (_toolbarEl) return _toolbarEl; + _toolbarEl = document.createElement('div'); + _toolbarEl.id = 'bulk-toolbar'; + _toolbarEl.setAttribute('role', 'toolbar'); + _toolbarEl.setAttribute('aria-label', 'Bulk actions'); + document.body.appendChild(_toolbarEl); + return _toolbarEl; +} + +/** Show the toolbar for a given section. */ +export function showBulkToolbar(section) { + if (_activeSection && _activeSection !== section) { + _activeSection.exitSelectionMode(); + } + _activeSection = section; + _render(); +} + +/** Hide the toolbar. */ +export function hideBulkToolbar() { + _activeSection = null; + if (_toolbarEl) _toolbarEl.classList.remove('visible'); +} + +/** Re-render toolbar (e.g. after selection count changes). */ +export function updateBulkToolbar() { + if (!_activeSection) return; + _render(); +} + +function _render() { + const el = _ensureEl(); + const section = _activeSection; + if (!section) { el.classList.remove('visible'); return; } + + const count = section._selected.size; + const actions = section.bulkActions || []; + + const actionBtns = actions.map(a => { + const cls = a.style === 'danger' ? 'btn btn-icon btn-danger bulk-action-btn' : 'btn btn-icon btn-secondary bulk-action-btn'; + const label = t(a.labelKey); + const inner = a.icon || label; + return ``; + }).join(''); + + el.innerHTML = ` + + ${t('bulk.selected_count', { count })} +
${actionBtns}
+ + `; + + // Select All checkbox + el.querySelector('.bulk-select-all-cb').addEventListener('change', (e) => { + if (e.target.checked) section.selectAll(); + else section.deselectAll(); + }); + + // Action buttons + el.querySelectorAll('[data-bulk-action]').forEach(btn => { + btn.addEventListener('click', () => _executeAction(btn.dataset.bulkAction)); + }); + + // Close button + el.querySelector('.bulk-close').addEventListener('click', () => { + section.exitSelectionMode(); + }); + + el.classList.add('visible'); +} + +async function _executeAction(actionKey) { + const section = _activeSection; + if (!section) return; + + const action = section.bulkActions.find(a => a.key === actionKey); + if (!action) return; + + const keys = [...section._selected]; + if (!keys.length) return; + + if (action.confirm) { + const msg = t(action.confirm, { count: keys.length }); + const ok = await showConfirm(msg); + if (!ok) return; + } + + try { + await action.handler(keys); + } catch (e) { + console.error(`Bulk action "${actionKey}" failed:`, e); + } + + section.exitSelectionMode(); +} diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index 3f8b323..365ef26 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -22,6 +22,8 @@ */ import { t } from './i18n.js'; +import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.js'; +import { ICON_LIST_CHECKS } from './icons.js'; const STORAGE_KEY = 'sections_collapsed'; const ORDER_PREFIX = 'card_order_'; @@ -46,8 +48,9 @@ export class CardSection { * @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id') * @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons) * @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items + * @param {Array} [opts.bulkActions] Bulk action descriptors: [{ key, labelKey, style?, confirm?, handler }] */ - constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey }) { + constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }) { this.sectionKey = sectionKey; this.titleKey = titleKey; this.gridClass = gridClass; @@ -56,11 +59,15 @@ export class CardSection { this.headerExtra = headerExtra || ''; this.collapsible = !!collapsible; this.emptyKey = emptyKey || ''; + this.bulkActions = bulkActions || null; this._filterValue = ''; this._lastItems = null; this._dragState = null; this._dragBound = false; - this._cachedCardRects = null; + // Bulk selection state + this._selecting = false; + this._selected = new Set(); + this._lastClickedKey = null; } /** True if this section's DOM element exists (i.e. not the first render). */ @@ -98,6 +105,7 @@ export class CardSection { ${t(this.titleKey)} ${count} ${this.headerExtra ? `${this.headerExtra}` : ''} + ${this.bulkActions ? `` : ''}
@@ -174,6 +182,53 @@ export class CardSection { updateResetVisibility(); } + // Bulk selection toggle button + if (this.bulkActions) { + const bulkBtn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`); + if (bulkBtn) { + bulkBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + bulkBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (this._selecting) this.exitSelectionMode(); + else this.enterSelectionMode(); + }); + } + + // Card click delegation for selection + // Ctrl+Click on a card auto-enters bulk mode if not already selecting + content.addEventListener('click', (e) => { + if (!this.keyAttr) return; + const card = e.target.closest(`[${this.keyAttr}]`); + if (!card) return; + // Don't hijack clicks on buttons, links, inputs inside cards + if (e.target.closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return; + + // Auto-enter selection mode on Ctrl/Cmd+Click + if (!this._selecting && (e.ctrlKey || e.metaKey)) { + this.enterSelectionMode(); + } + if (!this._selecting) return; + + const key = card.getAttribute(this.keyAttr); + if (!key) return; + + if (e.shiftKey && this._lastClickedKey) { + this._selectRange(content, this._lastClickedKey, key); + } else { + this._toggleSelect(key); + } + this._lastClickedKey = key; + }); + + // Escape to exit selection mode + this._escHandler = (e) => { + if (e.key === 'Escape' && this._selecting) { + this.exitSelectionMode(); + } + }; + document.addEventListener('keydown', this._escHandler); + } + // Tag card elements with their source HTML for future reconciliation this._tagCards(content); @@ -304,6 +359,22 @@ export class CardSection { this._applyFilter(content, this._filterValue); } + // Re-apply bulk selection state after reconcile + if (this._selecting && this.keyAttr) { + // Remove selected keys that were removed from DOM + for (const key of removed) this._selected.delete(key); + // Re-apply .card-selected on surviving cards + for (const key of this._selected) { + const card = content.querySelector(`[${this.keyAttr}="${key}"]`); + if (card) card.classList.add('card-selected'); + } + // Inject checkboxes on new/replaced cards + if (added.size > 0 || replaced.size > 0) { + this._injectCheckboxes(content); + } + updateBulkToolbar(); + } + return { added, replaced, removed }; } @@ -312,42 +383,6 @@ export class CardSection { for (const s of sections) s.bind(); } - /** Expand all given sections. */ - static expandAll(sections) { - const map = _getCollapsedMap(); - for (const s of sections) { - map[s.sectionKey] = false; - const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`); - const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`); - const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`); - if (content) content.style.display = ''; - if (section) section.classList.remove('cs-collapsed'); - if (header) { - const chevron = header.querySelector('.cs-chevron'); - if (chevron) chevron.style.transform = 'rotate(90deg)'; - } - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); - } - - /** Collapse all given sections. */ - static collapseAll(sections) { - const map = _getCollapsedMap(); - for (const s of sections) { - map[s.sectionKey] = true; - const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`); - const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`); - const section = document.querySelector(`[data-card-section="${s.sectionKey}"]`); - if (content) content.style.display = 'none'; - if (section) section.classList.add('cs-collapsed'); - if (header) { - const chevron = header.querySelector('.cs-chevron'); - if (chevron) chevron.style.transform = ''; - } - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); - } - /** Programmatically expand this section if collapsed. */ expand() { const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`); @@ -384,6 +419,130 @@ export class CardSection { return sorted; } + // ── Bulk selection ── + + enterSelectionMode() { + if (this._selecting) return; + this._selecting = true; + this._selected.clear(); + this._lastClickedKey = null; + const section = document.querySelector(`[data-card-section="${this.sectionKey}"]`); + if (section) section.classList.add('cs-selecting'); + const btn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`); + if (btn) btn.classList.add('active'); + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (content) this._injectCheckboxes(content); + showBulkToolbar(this); + } + + exitSelectionMode() { + if (!this._selecting) return; + this._selecting = false; + this._selected.clear(); + this._lastClickedKey = null; + const section = document.querySelector(`[data-card-section="${this.sectionKey}"]`); + if (section) section.classList.remove('cs-selecting'); + const btn = document.querySelector(`[data-cs-bulk="${this.sectionKey}"]`); + if (btn) btn.classList.remove('active'); + // Remove checkboxes and selection classes + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (content) { + content.querySelectorAll('.card-bulk-check').forEach(cb => cb.remove()); + content.querySelectorAll('.card-selected').forEach(c => c.classList.remove('card-selected')); + } + hideBulkToolbar(); + } + + _toggleSelect(key) { + if (this._selected.has(key)) this._selected.delete(key); + else this._selected.add(key); + this._applySelectionVisuals(); + updateBulkToolbar(); + } + + _selectRange(content, fromKey, toKey) { + const cards = [...content.querySelectorAll(`[${this.keyAttr}]`)]; + const keys = cards.map(c => c.getAttribute(this.keyAttr)); + const fromIdx = keys.indexOf(fromKey); + const toIdx = keys.indexOf(toKey); + if (fromIdx < 0 || toIdx < 0) return; + const lo = Math.min(fromIdx, toIdx); + const hi = Math.max(fromIdx, toIdx); + for (let i = lo; i <= hi; i++) { + const card = cards[i]; + if (card.style.display !== 'none') { // respect filter + this._selected.add(keys[i]); + } + } + this._applySelectionVisuals(); + updateBulkToolbar(); + } + + selectAll() { + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (!content || !this.keyAttr) return; + content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => { + if (card.style.display !== 'none') { + this._selected.add(card.getAttribute(this.keyAttr)); + } + }); + this._applySelectionVisuals(); + updateBulkToolbar(); + } + + deselectAll() { + this._selected.clear(); + this._applySelectionVisuals(); + updateBulkToolbar(); + } + + _visibleCardCount() { + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (!content || !this.keyAttr) return 0; + let count = 0; + content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => { + if (card.style.display !== 'none') count++; + }); + return count; + } + + _applySelectionVisuals() { + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (!content || !this.keyAttr) return; + content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => { + const key = card.getAttribute(this.keyAttr); + const selected = this._selected.has(key); + card.classList.toggle('card-selected', selected); + const cb = card.querySelector('.card-bulk-check'); + if (cb) cb.checked = selected; + }); + } + + _injectCheckboxes(content) { + if (!this.keyAttr) return; + content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => { + if (card.querySelector('.card-bulk-check')) return; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'card-bulk-check'; + cb.checked = this._selected.has(card.getAttribute(this.keyAttr)); + cb.addEventListener('click', (e) => { + e.stopPropagation(); + const key = card.getAttribute(this.keyAttr); + if (e.shiftKey && this._lastClickedKey) { + this._selectRange(content, this._lastClickedKey, key); + } else { + this._toggleSelect(key); + } + this._lastClickedKey = key; + }); + // Insert as first child of .card-top-actions, or prepend to card + const topActions = card.querySelector('.card-top-actions'); + if (topActions) topActions.prepend(cb); + else card.prepend(cb); + }); + } + _getSavedOrder() { try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; } catch { return []; } @@ -548,13 +707,15 @@ export class CardSection { }; const onMove = (ev) => this._onDragMove(ev); - const onUp = (ev) => { + const cleanup = () => { document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); - this._onDragEnd(ev); + document.removeEventListener('pointerup', cleanup); + document.removeEventListener('pointercancel', cleanup); + this._onDragEnd(); }; document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); + document.addEventListener('pointerup', cleanup); + document.addEventListener('pointercancel', cleanup); }); } @@ -573,16 +734,24 @@ export class CardSection { ds.clone.style.left = (e.clientX - ds.offsetX) + 'px'; ds.clone.style.top = (e.clientY - ds.offsetY) + 'px'; - // Only move placeholder when cursor enters a card's rect - const { card: target, before } = this._getDropTarget(e.clientX, e.clientY, ds.content); - if (!target) return; // cursor is in a gap — keep placeholder where it is - if (target === ds.lastTarget && before === ds.lastBefore) return; // same position - ds.lastTarget = target; - ds.lastBefore = before; - if (before) { - ds.content.insertBefore(ds.placeholder, target); - } else { - ds.content.insertBefore(ds.placeholder, target.nextSibling); + // Find which card the pointer is over — only move placeholder on hit + const hit = this._hitTestCard(e.clientX, e.clientY, ds); + if (hit) { + const r = hit.getBoundingClientRect(); + const before = e.clientX < r.left + r.width / 2; + // Track both target card and direction to avoid dead zones at last card + if (hit !== ds._lastHit || before !== ds._lastBefore) { + ds._lastHit = hit; + ds._lastBefore = before; + if (before) { + ds.content.insertBefore(ds.placeholder, hit); + } else if (hit.nextElementSibling && hit.nextElementSibling.hasAttribute(this.keyAttr)) { + ds.content.insertBefore(ds.placeholder, hit.nextElementSibling); + } else { + // Last card in grid — insert after it but before add-button + hit.after(ds.placeholder); + } + } } // Auto-scroll near viewport edges @@ -615,17 +784,13 @@ export class CardSection { // Hide original ds.card.style.display = 'none'; - ds.content.classList.add('cs-dragging'); document.body.classList.add('cs-drag-active'); - // Cache card bounding rects for the duration of the drag - this._cachedCardRects = this._buildCardRectCache(ds.content); } _onDragEnd() { const ds = this._dragState; this._dragState = null; - this._cachedCardRects = null; if (!ds || !ds.started) return; // Cancel auto-scroll @@ -636,7 +801,6 @@ export class CardSection { ds.card.style.display = ''; ds.placeholder.remove(); ds.clone.remove(); - ds.content.classList.remove('cs-dragging'); document.body.classList.remove('cs-drag-active'); // Save new order from DOM @@ -651,24 +815,22 @@ export class CardSection { } } - _buildCardRectCache(content) { - const cards = content.querySelectorAll(`[${this.keyAttr}]`); - const rects = []; + /** + * Point-in-rect hit test: find which card the pointer is directly over. + * Only triggers placeholder move when cursor is inside a card — dragging + * over gaps keeps the placeholder in its last position. + */ + _hitTestCard(x, y, ds) { + const cards = ds.content.querySelectorAll(`[${this.keyAttr}]`); for (const card of cards) { + if (card === ds.card) continue; if (card.style.display === 'none') continue; - rects.push({ card, rect: card.getBoundingClientRect() }); - } - return rects; - } - - _getDropTarget(x, y, content) { - const rects = this._cachedCardRects || []; - for (const { card, rect: r } of rects) { + const r = card.getBoundingClientRect(); if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { - return { card, before: x < r.left + r.width / 2 }; + return card; } } - return { card: null, before: false }; + return null; } _readDomOrder(content) { diff --git a/server/src/wled_controller/static/js/core/color-picker.js b/server/src/wled_controller/static/js/core/color-picker.js index 3eb5696..2b9cffd 100644 --- a/server/src/wled_controller/static/js/core/color-picker.js +++ b/server/src/wled_controller/static/js/core/color-picker.js @@ -143,6 +143,9 @@ function _cpClosePopover(pop) { } const card = pop.closest('.card, .template-card'); if (card) card.classList.remove('cp-elevated'); + // Remove graph color picker overlay (swatch + popover wrapper) + const graphOverlay = pop.closest('.graph-cp-overlay'); + if (graphOverlay) graphOverlay.remove(); } window._cpPick = function (id, hex) { diff --git a/server/src/wled_controller/static/js/core/icon-paths.js b/server/src/wled_controller/static/js/core/icon-paths.js index bf5eceb..22a6854 100644 --- a/server/src/wled_controller/static/js/core/icon-paths.js +++ b/server/src/wled_controller/static/js/core/icon-paths.js @@ -78,3 +78,6 @@ export const cpu = ' + fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning'); + else showToast(t('automations.updated'), 'success'); + automationsCacheObj.invalidate(); + loadAutomations(); +} + +async function _bulkDisableAutomations(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning'); + else showToast(t('automations.updated'), 'success'); + automationsCacheObj.invalidate(); + loadAutomations(); +} + +async function _bulkDeleteAutomations(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/automations/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('automations.deleted'), 'success'); + automationsCacheObj.invalidate(); + loadAutomations(); +} + +const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations', bulkActions: [ + { key: 'enable', labelKey: 'bulk.enable', icon: ICON_OK, handler: _bulkEnableAutomations }, + { key: 'disable', labelKey: 'bulk.disable', icon: ICON_CIRCLE_OFF, handler: _bulkDisableAutomations }, + { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteAutomations }, +] }); /* ── Condition logic IconSelect ───────────────────────────────── */ @@ -101,14 +140,6 @@ export async function loadAutomations() { } } -export function expandAllAutomationSections() { - CardSection.expandAll([csAutomations, csScenes]); -} - -export function collapseAllAutomationSections() { - CardSection.collapseAll([csAutomations, csScenes]); -} - function renderAutomations(automations, sceneMap) { const container = document.getElementById('automations-content'); @@ -119,7 +150,7 @@ function renderAutomations(automations, sceneMap) { csAutomations.reconcile(autoItems); csScenes.reconcile(sceneItems); } else { - const toolbar = `
`; + const toolbar = `
`; container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); csAutomations.bind(); csScenes.bind(); diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index fca0b0e..4d84fb3 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -900,13 +900,15 @@ function _initCompositeLayerDrag(list) { }; const onMove = (ev) => _onCompositeLayerDragMove(ev); - const onUp = () => { + const cleanup = () => { document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); + document.removeEventListener('pointerup', cleanup); + document.removeEventListener('pointercancel', cleanup); _onCompositeLayerDragEnd(); }; document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); + document.addEventListener('pointerup', cleanup); + document.addEventListener('pointercancel', cleanup); }, { capture: false }); } diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index 5bc1fee..401979c 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -9,7 +9,7 @@ import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { - ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, + ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_TRASH, } from '../core/icons.js'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js'; import { TagInput, renderTagChips } from '../core/tag-input.js'; @@ -44,6 +44,19 @@ export const csScenes = new CardSection('scenes', { addCardOnclick: "openScenePresetCapture()", keyAttr: 'data-scene-id', emptyKey: 'section.empty.scenes', + bulkActions: [{ + key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', + handler: async (ids) => { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('scenes.deleted'), 'success'); + scenePresetsCache.invalidate(); + if (window.loadAutomations) window.loadAutomations(); + }, + }], }); export function createSceneCard(preset) { diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index ad65bbe..427e5ab 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -54,7 +54,7 @@ import { ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, - ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, + ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; @@ -72,20 +72,44 @@ let _ppTemplateTagsInput = null; let _audioTemplateTagsInput = null; let _csptTagsInput = null; +// ── Bulk action handlers ── +function _bulkDeleteFactory(endpoint, cache, toast) { + return async (ids) => { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t(toast), 'success'); + cache.invalidate(); + await loadPictureSources(); + }; +} + +const _streamDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('picture-sources', streamsCache, 'streams.deleted') }]; +const _captureTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('capture-templates', captureTemplatesCache, 'templates.deleted') }]; +const _ppTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('postprocessing-templates', ppTemplatesCache, 'templates.deleted') }]; +const _audioSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-sources', audioSourcesCache, 'audio_source.deleted') }]; +const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('audio-templates', audioTemplatesCache, 'templates.deleted') }]; +const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }]; +const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }]; +const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }]; +const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }]; + // ── Card section instances ── -const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' }); -const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' }); -const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' }); -const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' }); -const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' }); -const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' }); -const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' }); -const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' }); -const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates' }); -const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips' }); -const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' }); -const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' }); -const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' }); +const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); +const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction }); +const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); +const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates', bulkActions: _ppTemplateDeleteAction }); +const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction }); +const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources', bulkActions: _audioSourceDeleteAction }); +const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); +const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction }); +const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates', bulkActions: _audioTemplateDeleteAction }); +const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction }); +const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction }); +const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); +const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); // Re-render picture sources when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); @@ -1300,16 +1324,6 @@ const _streamSectionMap = { sync: [csSyncClocks], }; -export function expandAllStreamSections() { - const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; - CardSection.expandAll(_streamSectionMap[activeTab] || []); -} - -export function collapseAllStreamSections() { - const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; - CardSection.collapseAll(_streamSectionMap[activeTab] || []); -} - function renderPictureSourcesList(streams) { const container = document.getElementById('streams-list'); const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; @@ -1712,7 +1726,7 @@ function renderPictureSourcesList(streams) { CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]); // Render tree sidebar with expand/collapse buttons - _streamsTree.setExtraHtml(``); + _streamsTree.setExtraHtml(``); _streamsTree.update(treeGroups, activeTab); _streamsTree.observeSections('streams-list', { 'raw-streams': 'raw', 'raw-templates': 'raw_templates', @@ -2431,6 +2445,14 @@ function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) { const container = document.getElementById(containerId); if (!container) return; + // Update refs each render so the pointerdown closure always sees current data + container._filterDragFilters = filtersArr; + container._filterDragRerender = rerenderFn; + + // Guard against stacking listeners across re-renders + if (container._filterDragBound) return; + container._filterDragBound = true; + container.addEventListener('pointerdown', (e) => { const handle = e.target.closest('.pp-filter-drag-handle'); if (!handle) return; @@ -2450,18 +2472,20 @@ function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) { offsetY: 0, fromIndex, scrollRaf: null, - filtersArr, - rerenderFn, + filtersArr: container._filterDragFilters, + rerenderFn: container._filterDragRerender, }; const onMove = (ev) => _onFilterDragMove(ev); - const onUp = () => { + const cleanup = () => { document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); + document.removeEventListener('pointerup', cleanup); + document.removeEventListener('pointercancel', cleanup); _onFilterDragEnd(); }; document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); + document.addEventListener('pointerup', cleanup); + document.addEventListener('pointercancel', cleanup); }); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index c08003e..d22d9ef 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -24,7 +24,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, - ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, + ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE, ICON_TRASH, } from '../core/icons.js'; import { EntitySelect } from '../core/entity-palette.js'; import { IconSelect } from '../core/icon-select.js'; @@ -39,11 +39,73 @@ import { updateSubTabHash, updateTabBadge } from './tabs.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) +// ── Bulk action handlers ── +async function _bulkStartTargets(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/output-targets/${id}/start`, { method: 'POST' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} started`, 'warning'); + else showToast(t('device.started'), 'success'); +} + +async function _bulkStopTargets(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/output-targets/${id}/stop`, { method: 'POST' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} stopped`, 'warning'); + else showToast(t('device.stopped'), 'success'); +} + +async function _bulkDeleteTargets(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/output-targets/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('targets.deleted'), 'success'); + outputTargetsCache.invalidate(); + await loadTargetsTab(); +} + +async function _bulkDeleteDevices(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/devices/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('device.removed'), 'success'); + devicesCache.invalidate(); + await loadTargetsTab(); +} + +async function _bulkDeletePatternTemplates(ids) { + const results = await Promise.allSettled(ids.map(id => + fetchWithAuth(`/pattern-templates/${id}`, { method: 'DELETE' }) + )); + const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('targets.deleted'), 'success'); + patternTemplatesCache.invalidate(); + await loadTargetsTab(); +} + +const _targetBulkActions = [ + { key: 'start', labelKey: 'bulk.start', icon: ICON_START, handler: _bulkStartTargets }, + { key: 'stop', labelKey: 'bulk.stop', icon: ICON_STOP, handler: _bulkStopTargets }, + { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteTargets }, +]; + // ── Card section instances ── -const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' }); -const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `` }); -const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `` }); -const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' }); +const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices', bulkActions: [ + { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices }, +] }); +const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: ``, bulkActions: _targetBulkActions }); +const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: ``, bulkActions: _targetBulkActions }); +const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates', bulkActions: [ + { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeletePatternTemplates }, +] }); // Re-render targets tab when language changes (only if tab is active) document.addEventListener('languageChanged', () => { @@ -521,16 +583,6 @@ const _targetSectionMap = { 'kc-patterns': [csPatternTemplates], }; -export function expandAllTargetSections() { - const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices'; - CardSection.expandAll(_targetSectionMap[activeSubTab] || []); -} - -export function collapseAllTargetSections() { - const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices'; - CardSection.collapseAll(_targetSectionMap[activeSubTab] || []); -} - let _loadTargetsLock = false; let _actionInFlight = false; @@ -682,7 +734,7 @@ export async function loadTargetsTab() { CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]); // Render tree sidebar with expand/collapse buttons - _targetsTree.setExtraHtml(``); + _targetsTree.setExtraHtml(``); _targetsTree.update(treeGroups, activeLeaf); _targetsTree.observeSections('targets-panel-content'); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8448788..091ea7e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1722,5 +1722,19 @@ "section.empty.sync_clocks": "No sync clocks yet. Click + to add one.", "section.empty.cspt": "No CSS processing templates yet. Click + to add one.", "section.empty.automations": "No automations yet. Click + to add one.", - "section.empty.scenes": "No scene presets yet. Click + to add one." + "section.empty.scenes": "No scene presets yet. Click + to add one.", + + "bulk.select": "Select", + "bulk.cancel": "Cancel", + "bulk.selected_count.one": "{count} selected", + "bulk.selected_count.other": "{count} selected", + "bulk.select_all": "Select all", + "bulk.deselect_all": "Deselect all", + "bulk.delete": "Delete", + "bulk.start": "Start", + "bulk.stop": "Stop", + "bulk.enable": "Enable", + "bulk.disable": "Disable", + "bulk.confirm_delete.one": "Delete {count} item?", + "bulk.confirm_delete.other": "Delete {count} items?" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 41b180d..ca9d1a1 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1722,5 +1722,21 @@ "section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.", "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", - "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления." + "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", + + "bulk.select": "Выбрать", + "bulk.cancel": "Отмена", + "bulk.selected_count.one": "{count} выбран", + "bulk.selected_count.few": "{count} выбрано", + "bulk.selected_count.many": "{count} выбрано", + "bulk.select_all": "Выбрать все", + "bulk.deselect_all": "Снять выбор", + "bulk.delete": "Удалить", + "bulk.start": "Запустить", + "bulk.stop": "Остановить", + "bulk.enable": "Включить", + "bulk.disable": "Выключить", + "bulk.confirm_delete.one": "Удалить {count} элемент?", + "bulk.confirm_delete.few": "Удалить {count} элемента?", + "bulk.confirm_delete.many": "Удалить {count} элементов?" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 092b8ae..ccb346d 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1722,5 +1722,19 @@ "section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。", "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", "section.empty.automations": "暂无自动化。点击 + 添加。", - "section.empty.scenes": "暂无场景预设。点击 + 添加。" + "section.empty.scenes": "暂无场景预设。点击 + 添加。", + + "bulk.select": "选择", + "bulk.cancel": "取消", + "bulk.selected_count.one": "已选 {count} 项", + "bulk.selected_count.other": "已选 {count} 项", + "bulk.select_all": "全选", + "bulk.deselect_all": "取消全选", + "bulk.delete": "删除", + "bulk.start": "启动", + "bulk.stop": "停止", + "bulk.enable": "启用", + "bulk.disable": "禁用", + "bulk.confirm_delete.one": "删除 {count} 项?", + "bulk.confirm_delete.other": "删除 {count} 项?" }