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.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} 项?"
}