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:
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
115
server/src/wled_controller/static/js/core/bulk-toolbar.js
Normal file
115
server/src/wled_controller/static/js/core/bulk-toolbar.js
Normal 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')}">✕</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();
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"/>';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} элементов?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} 项?"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user