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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 01:21:27 +03:00
parent f4647027d2
commit 122e95545c
18 changed files with 771 additions and 149 deletions

View File

@@ -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`). 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) ## 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`). **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`).

View File

@@ -595,7 +595,13 @@ class WledTargetProcessor(TargetProcessor):
fps_samples: collections.deque = collections.deque(maxlen=10) fps_samples: collections.deque = collections.deque(maxlen=10)
_fps_sum = 0.0 _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_send_time = 0.0
_last_preview_broadcast = 0.0 _last_preview_broadcast = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
@@ -839,7 +845,7 @@ class WledTargetProcessor(TargetProcessor):
send_timestamps.append(now) send_timestamps.append(now)
self._metrics.frames_keepalive += 1 self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 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) await asyncio.sleep(SKIP_REPOLL)
continue continue
@@ -864,7 +870,7 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 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 is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
@@ -923,7 +929,7 @@ class WledTargetProcessor(TargetProcessor):
processing_time = now - loop_start processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 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: except Exception as e:
self._metrics.errors_count += 1 self._metrics.errors_count += 1

View File

@@ -27,9 +27,18 @@
padding: 0 4px; 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 { [data-automation-id] .stream-card-prop {
max-width: 280px; max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* Automation condition editor rows */ /* Automation condition editor rows */

View File

@@ -267,8 +267,9 @@ body.cs-drag-active .card-drag-handle {
opacity: 0 !important; opacity: 0 !important;
} }
/* Hide drag handles when filter is active */ /* Hide drag handles when filter is active or bulk selecting */
.cs-filtering .card-drag-handle { .cs-filtering .card-drag-handle,
.cs-selecting .card-drag-handle {
display: none; display: none;
} }
@@ -1112,3 +1113,146 @@ ul.section-tip li {
.led-preview-layers:hover .led-preview-layer-label { .led-preview-layers:hover .led-preview-layer-label {
opacity: 1; 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);
}

View File

@@ -68,7 +68,6 @@ import {
showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT, showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT,
csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption, csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption,
renderCSPTModalFilterList, renderCSPTModalFilterList,
expandAllStreamSections, collapseAllStreamSections,
} from './features/streams.js'; } from './features/streams.js';
import { import {
createKCTargetCard, testKCTarget, createKCTargetCard, testKCTarget,
@@ -90,7 +89,6 @@ import {
loadAutomations, openAutomationEditor, closeAutomationEditorModal, loadAutomations, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition, saveAutomationEditor, addAutomationCondition,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
expandAllAutomationSections, collapseAllAutomationSections,
} from './features/automations.js'; } from './features/automations.js';
import { import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
@@ -111,7 +109,6 @@ import {
stopAllLedTargets, stopAllKCTargets, stopAllLedTargets, stopAllKCTargets,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview, cloneTarget, toggleLedPreview,
expandAllTargetSections, collapseAllTargetSections,
disconnectAllLedPreviewWS, disconnectAllLedPreviewWS,
} from './features/targets.js'; } from './features/targets.js';
@@ -275,7 +272,6 @@ Object.assign(window, {
// streams / capture templates / PP templates // streams / capture templates / PP templates
loadPictureSources, loadPictureSources,
switchStreamTab, switchStreamTab,
expandAllStreamSections, collapseAllStreamSections,
showAddTemplateModal, showAddTemplateModal,
editTemplate, editTemplate,
closeTemplateModal, closeTemplateModal,
@@ -377,8 +373,6 @@ Object.assign(window, {
cloneAutomation, cloneAutomation,
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
expandAllAutomationSections,
collapseAllAutomationSections,
// scene presets // scene presets
openScenePresetCapture, openScenePresetCapture,
@@ -404,7 +398,6 @@ Object.assign(window, {
// targets // targets
loadTargetsTab, loadTargetsTab,
switchTargetSubTab, switchTargetSubTab,
expandAllTargetSections, collapseAllTargetSections,
showTargetEditor, showTargetEditor,
closeTargetEditorModal, closeTargetEditorModal,
forceCloseTargetEditorModal, forceCloseTargetEditorModal,

View File

@@ -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 `<button class="${cls}" data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
}).join('');
el.innerHTML = `
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
</label>
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
<div class="bulk-actions">${actionBtns}</div>
<button class="bulk-close" title="${t('bulk.cancel')}">&#x2715;</button>
`;
// 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();
}

View File

@@ -22,6 +22,8 @@
*/ */
import { t } from './i18n.js'; 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 STORAGE_KEY = 'sections_collapsed';
const ORDER_PREFIX = 'card_order_'; 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.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.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 {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.sectionKey = sectionKey;
this.titleKey = titleKey; this.titleKey = titleKey;
this.gridClass = gridClass; this.gridClass = gridClass;
@@ -56,11 +59,15 @@ export class CardSection {
this.headerExtra = headerExtra || ''; this.headerExtra = headerExtra || '';
this.collapsible = !!collapsible; this.collapsible = !!collapsible;
this.emptyKey = emptyKey || ''; this.emptyKey = emptyKey || '';
this.bulkActions = bulkActions || null;
this._filterValue = ''; this._filterValue = '';
this._lastItems = null; this._lastItems = null;
this._dragState = null; this._dragState = null;
this._dragBound = false; 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). */ /** True if this section's DOM element exists (i.e. not the first render). */
@@ -98,6 +105,7 @@ export class CardSection {
<span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span> <span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span> <span class="cs-count">${count}</span>
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''} ${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
<div class="cs-filter-wrap"> <div class="cs-filter-wrap">
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}" <input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off"> data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
@@ -174,6 +182,53 @@ export class CardSection {
updateResetVisibility(); 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 // Tag card elements with their source HTML for future reconciliation
this._tagCards(content); this._tagCards(content);
@@ -304,6 +359,22 @@ export class CardSection {
this._applyFilter(content, this._filterValue); 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 }; return { added, replaced, removed };
} }
@@ -312,42 +383,6 @@ export class CardSection {
for (const s of sections) s.bind(); 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. */ /** Programmatically expand this section if collapsed. */
expand() { expand() {
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`); const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
@@ -384,6 +419,130 @@ export class CardSection {
return sorted; 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() { _getSavedOrder() {
try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; } try { return JSON.parse(localStorage.getItem(ORDER_PREFIX + this.sectionKey)) || []; }
catch { return []; } catch { return []; }
@@ -548,13 +707,15 @@ export class CardSection {
}; };
const onMove = (ev) => this._onDragMove(ev); const onMove = (ev) => this._onDragMove(ev);
const onUp = (ev) => { const cleanup = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', cleanup);
this._onDragEnd(ev); document.removeEventListener('pointercancel', cleanup);
this._onDragEnd();
}; };
document.addEventListener('pointermove', onMove); 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.left = (e.clientX - ds.offsetX) + 'px';
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px'; ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
// Only move placeholder when cursor enters a card's rect // Find which card the pointer is over — only move placeholder on hit
const { card: target, before } = this._getDropTarget(e.clientX, e.clientY, ds.content); const hit = this._hitTestCard(e.clientX, e.clientY, ds);
if (!target) return; // cursor is in a gap — keep placeholder where it is if (hit) {
if (target === ds.lastTarget && before === ds.lastBefore) return; // same position const r = hit.getBoundingClientRect();
ds.lastTarget = target; const before = e.clientX < r.left + r.width / 2;
ds.lastBefore = before; // Track both target card and direction to avoid dead zones at last card
if (before) { if (hit !== ds._lastHit || before !== ds._lastBefore) {
ds.content.insertBefore(ds.placeholder, target); ds._lastHit = hit;
} else { ds._lastBefore = before;
ds.content.insertBefore(ds.placeholder, target.nextSibling); 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 // Auto-scroll near viewport edges
@@ -615,17 +784,13 @@ export class CardSection {
// Hide original // Hide original
ds.card.style.display = 'none'; ds.card.style.display = 'none';
ds.content.classList.add('cs-dragging');
document.body.classList.add('cs-drag-active'); document.body.classList.add('cs-drag-active');
// Cache card bounding rects for the duration of the drag
this._cachedCardRects = this._buildCardRectCache(ds.content);
} }
_onDragEnd() { _onDragEnd() {
const ds = this._dragState; const ds = this._dragState;
this._dragState = null; this._dragState = null;
this._cachedCardRects = null;
if (!ds || !ds.started) return; if (!ds || !ds.started) return;
// Cancel auto-scroll // Cancel auto-scroll
@@ -636,7 +801,6 @@ export class CardSection {
ds.card.style.display = ''; ds.card.style.display = '';
ds.placeholder.remove(); ds.placeholder.remove();
ds.clone.remove(); ds.clone.remove();
ds.content.classList.remove('cs-dragging');
document.body.classList.remove('cs-drag-active'); document.body.classList.remove('cs-drag-active');
// Save new order from DOM // Save new order from DOM
@@ -651,24 +815,22 @@ export class CardSection {
} }
} }
_buildCardRectCache(content) { /**
const cards = content.querySelectorAll(`[${this.keyAttr}]`); * Point-in-rect hit test: find which card the pointer is directly over.
const rects = []; * 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) { for (const card of cards) {
if (card === ds.card) continue;
if (card.style.display === 'none') continue; if (card.style.display === 'none') continue;
rects.push({ card, rect: card.getBoundingClientRect() }); const r = card.getBoundingClientRect();
}
return rects;
}
_getDropTarget(x, y, content) {
const rects = this._cachedCardRects || [];
for (const { card, rect: r } of rects) {
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { 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) { _readDomOrder(content) {

View File

@@ -143,6 +143,9 @@ function _cpClosePopover(pop) {
} }
const card = pop.closest('.card, .template-card'); const card = pop.closest('.card, .template-card');
if (card) card.classList.remove('cp-elevated'); 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) { window._cpPick = function (id, hex) {

View File

@@ -78,3 +78,6 @@ export const cpu = '<rect x="4" y="4" width="16" height="16" rx="2"/><r
export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>'; export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/>';
export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>'; export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>';
export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>'; export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>';
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';

View File

@@ -179,3 +179,6 @@ export const ICON_CPU = _svg(P.cpu);
export const ICON_KEYBOARD = _svg(P.keyboard); export const ICON_KEYBOARD = _svg(P.keyboard);
export const ICON_MOUSE = _svg(P.mouse); export const ICON_MOUSE = _svg(P.mouse);
export const ICON_HEADPHONES = _svg(P.headphones); export const ICON_HEADPHONES = _svg(P.headphones);
export const ICON_TRASH = _svg(P.trash2);
export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff);

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js'; import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF } from '../core/icons.js';
import * as P from '../core/icon-paths.js'; import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -43,7 +43,46 @@ class AutomationEditorModal extends Modal {
} }
const automationModal = new AutomationEditorModal(); const automationModal = new AutomationEditorModal();
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations' });
// ── Bulk action handlers ──
async function _bulkEnableAutomations(ids) {
const results = await Promise.allSettled(ids.map(id =>
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 ───────────────────────────────── */ /* ── 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) { function renderAutomations(automations, sceneMap) {
const container = document.getElementById('automations-content'); const container = document.getElementById('automations-content');
@@ -119,7 +150,7 @@ function renderAutomations(automations, sceneMap) {
csAutomations.reconcile(autoItems); csAutomations.reconcile(autoItems);
csScenes.reconcile(sceneItems); csScenes.reconcile(sceneItems);
} else { } else {
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`; const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
csAutomations.bind(); csAutomations.bind();
csScenes.bind(); csScenes.bind();

View File

@@ -900,13 +900,15 @@ function _initCompositeLayerDrag(list) {
}; };
const onMove = (ev) => _onCompositeLayerDragMove(ev); const onMove = (ev) => _onCompositeLayerDragMove(ev);
const onUp = () => { const cleanup = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onCompositeLayerDragEnd(); _onCompositeLayerDragEnd();
}; };
document.addEventListener('pointermove', onMove); document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp); document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}, { capture: false }); }, { capture: false });
} }

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { 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'; } from '../core/icons.js';
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js';
import { TagInput, renderTagChips } from '../core/tag-input.js'; import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -44,6 +44,19 @@ export const csScenes = new CardSection('scenes', {
addCardOnclick: "openScenePresetCapture()", addCardOnclick: "openScenePresetCapture()",
keyAttr: 'data-scene-id', keyAttr: 'data-scene-id',
emptyKey: 'section.empty.scenes', 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) { export function createSceneCard(preset) {

View File

@@ -54,7 +54,7 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, 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_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, 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'; } from '../core/icons.js';
import * as P from '../core/icon-paths.js'; import * as P from '../core/icon-paths.js';
@@ -72,20 +72,44 @@ let _ppTemplateTagsInput = null;
let _audioTemplateTagsInput = null; let _audioTemplateTagsInput = null;
let _csptTagsInput = 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 ── // ── 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 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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' }); 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 // Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); }); document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -1300,16 +1324,6 @@ const _streamSectionMap = {
sync: [csSyncClocks], 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) { function renderPictureSourcesList(streams) {
const container = document.getElementById('streams-list'); const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw'; 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]); CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
// Render tree sidebar with expand/collapse buttons // Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`); _streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_streamsTree.update(treeGroups, activeTab); _streamsTree.update(treeGroups, activeTab);
_streamsTree.observeSections('streams-list', { _streamsTree.observeSections('streams-list', {
'raw-streams': 'raw', 'raw-templates': 'raw_templates', 'raw-streams': 'raw', 'raw-templates': 'raw_templates',
@@ -2431,6 +2445,14 @@ function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; 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) => { container.addEventListener('pointerdown', (e) => {
const handle = e.target.closest('.pp-filter-drag-handle'); const handle = e.target.closest('.pp-filter-drag-handle');
if (!handle) return; if (!handle) return;
@@ -2450,18 +2472,20 @@ function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) {
offsetY: 0, offsetY: 0,
fromIndex, fromIndex,
scrollRaf: null, scrollRaf: null,
filtersArr, filtersArr: container._filterDragFilters,
rerenderFn, rerenderFn: container._filterDragRerender,
}; };
const onMove = (ev) => _onFilterDragMove(ev); const onMove = (ev) => _onFilterDragMove(ev);
const onUp = () => { const cleanup = () => {
document.removeEventListener('pointermove', onMove); document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp); document.removeEventListener('pointerup', cleanup);
document.removeEventListener('pointercancel', cleanup);
_onFilterDragEnd(); _onFilterDragEnd();
}; };
document.addEventListener('pointermove', onMove); document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp); document.addEventListener('pointerup', cleanup);
document.addEventListener('pointercancel', cleanup);
}); });
} }

View File

@@ -24,7 +24,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, 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_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'; } from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js'; import { EntitySelect } from '../core/entity-palette.js';
import { IconSelect } from '../core/icon-select.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 // createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab) // (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 ── // ── 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 csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices', bulkActions: [
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` }); { key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteDevices },
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: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` }); ] });
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 csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, 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: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>`, 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) // Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {
@@ -521,16 +583,6 @@ const _targetSectionMap = {
'kc-patterns': [csPatternTemplates], '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 _loadTargetsLock = false;
let _actionInFlight = false; let _actionInFlight = false;
@@ -682,7 +734,7 @@ export async function loadTargetsTab() {
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]); CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
// Render tree sidebar with expand/collapse buttons // Render tree sidebar with expand/collapse buttons
_targetsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`); _targetsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_targetsTree.update(treeGroups, activeLeaf); _targetsTree.update(treeGroups, activeLeaf);
_targetsTree.observeSections('targets-panel-content'); _targetsTree.observeSections('targets-panel-content');
} }

View File

@@ -1722,5 +1722,19 @@
"section.empty.sync_clocks": "No sync clocks yet. Click + to add one.", "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.cspt": "No CSS processing templates yet. Click + to add one.",
"section.empty.automations": "No automations 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?"
} }

View File

@@ -1722,5 +1722,21 @@
"section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.", "section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.",
"section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.",
"section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", "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} элементов?"
} }

View File

@@ -1722,5 +1722,19 @@
"section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。", "section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。",
"section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。",
"section.empty.automations": "暂无自动化。点击 + 添加。", "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} 项?"
} }