Add command palette entity selector for all editor dropdowns
Replace plain <select> dropdowns with a searchable command palette modal for 16 entity selectors across 6 editors (targets, streams, CSS sources, value sources, audio sources, pattern templates). Unified EntityPalette singleton + EntitySelect wrapper in core/entity-palette.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -583,3 +583,145 @@ textarea:focus-visible {
|
|||||||
.icon-select-cell { padding: 8px 4px; }
|
.icon-select-cell { padding: 8px 4px; }
|
||||||
.icon-select-grid { gap: 4px; padding: 4px; }
|
.icon-select-grid { gap: 4px; padding: 4px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Entity Palette (command-palette style selector) ─────── */
|
||||||
|
|
||||||
|
.entity-palette-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: min(20vh, 120px);
|
||||||
|
}
|
||||||
|
.entity-palette-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.entity-palette {
|
||||||
|
width: min(500px, 90vw);
|
||||||
|
max-height: 60vh;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.entity-palette-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.entity-palette-search-row .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.entity-palette-input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.entity-palette-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.entity-palette-empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.entity-palette-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.entity-palette-item:hover,
|
||||||
|
.entity-palette-item.ep-highlight {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.entity-palette-item.ep-current {
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
padding-left: 11px;
|
||||||
|
}
|
||||||
|
.ep-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ep-item-icon .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.ep-item-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ep-item-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entity Select trigger (replaces <select>) */
|
||||||
|
.entity-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.entity-select-trigger:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.es-trigger-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.es-trigger-icon .icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
.es-trigger-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.es-trigger-none {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.es-trigger-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
267
server/src/wled_controller/static/js/core/entity-palette.js
Normal file
267
server/src/wled_controller/static/js/core/entity-palette.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Command-palette style entity selector.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { EntityPalette, EntitySelect } from '../core/entity-palette.js';
|
||||||
|
*
|
||||||
|
* // Direct use (promise-based):
|
||||||
|
* const value = await EntityPalette.pick({
|
||||||
|
* items: [{ value: 'abc', label: 'My Device', icon: '<svg>…</svg>', desc: '192.168.1.1' }],
|
||||||
|
* current: 'abc',
|
||||||
|
* placeholder: 'Search devices...',
|
||||||
|
* });
|
||||||
|
* // value = 'abc' (selected) or undefined (cancelled)
|
||||||
|
*
|
||||||
|
* // Wrapper (replaces a <select> visually):
|
||||||
|
* const sel = new EntitySelect({
|
||||||
|
* target: document.getElementById('my-select'),
|
||||||
|
* getItems: () => devices.map(d => ({ value: d.id, label: d.name, icon: '…' })),
|
||||||
|
* placeholder: 'Search devices...',
|
||||||
|
* onChange: (value) => { … },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* The original <select> is hidden but stays in the DOM (value kept in sync).
|
||||||
|
* Call sel.refresh() after repopulating the <select>, sel.destroy() to remove.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICON_SEARCH } from './icons.js';
|
||||||
|
|
||||||
|
// ── EntityPalette (singleton modal) ─────────────────────────
|
||||||
|
|
||||||
|
let _instance = null;
|
||||||
|
|
||||||
|
export class EntityPalette {
|
||||||
|
/**
|
||||||
|
* Open the palette and return a promise.
|
||||||
|
* Resolves to selected value (string) or undefined if cancelled.
|
||||||
|
*/
|
||||||
|
static pick(opts) {
|
||||||
|
if (!_instance) _instance = new EntityPalette();
|
||||||
|
return _instance._pick(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._overlay = document.createElement('div');
|
||||||
|
this._overlay.className = 'entity-palette-overlay';
|
||||||
|
this._overlay.innerHTML = `
|
||||||
|
<div class="entity-palette">
|
||||||
|
<div class="entity-palette-search-row">
|
||||||
|
${ICON_SEARCH}
|
||||||
|
<input type="text" class="entity-palette-input" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="entity-palette-list"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(this._overlay);
|
||||||
|
|
||||||
|
this._input = this._overlay.querySelector('.entity-palette-input');
|
||||||
|
this._list = this._overlay.querySelector('.entity-palette-list');
|
||||||
|
this._resolve = null;
|
||||||
|
this._items = [];
|
||||||
|
this._filtered = [];
|
||||||
|
this._highlightIdx = 0;
|
||||||
|
|
||||||
|
this._overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this._overlay) this._cancel();
|
||||||
|
});
|
||||||
|
this._input.addEventListener('input', () => this._filter());
|
||||||
|
this._input.addEventListener('keydown', (e) => this._onKeyDown(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
_pick({ items, current, placeholder, allowNone, noneLabel }) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this._resolve = resolve;
|
||||||
|
this._items = items || [];
|
||||||
|
this._currentValue = current;
|
||||||
|
this._allowNone = allowNone;
|
||||||
|
this._noneLabel = noneLabel;
|
||||||
|
|
||||||
|
this._input.placeholder = placeholder || '';
|
||||||
|
this._input.value = '';
|
||||||
|
|
||||||
|
this._filter();
|
||||||
|
this._overlay.classList.add('open');
|
||||||
|
// Focus after paint so the overlay is visible
|
||||||
|
requestAnimationFrame(() => this._input.focus());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFullList() {
|
||||||
|
const all = [];
|
||||||
|
if (this._allowNone) {
|
||||||
|
all.push({ value: '', label: this._noneLabel || '—', icon: '', desc: '', _isNone: true });
|
||||||
|
}
|
||||||
|
all.push(...this._items);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
_filter() {
|
||||||
|
const query = this._input.value.toLowerCase().trim();
|
||||||
|
const all = this._buildFullList();
|
||||||
|
this._filtered = query
|
||||||
|
? all.filter(i => i.label.toLowerCase().includes(query) || (i.desc && i.desc.toLowerCase().includes(query)))
|
||||||
|
: all;
|
||||||
|
|
||||||
|
// Highlight current value, or first item
|
||||||
|
this._highlightIdx = this._filtered.findIndex(i => i.value === this._currentValue);
|
||||||
|
if (this._highlightIdx === -1) this._highlightIdx = 0;
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
if (this._filtered.length === 0) {
|
||||||
|
this._list.innerHTML = '<div class="entity-palette-empty">—</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._list.innerHTML = this._filtered.map((item, i) => {
|
||||||
|
const cls = [
|
||||||
|
'entity-palette-item',
|
||||||
|
i === this._highlightIdx ? 'ep-highlight' : '',
|
||||||
|
item.value === this._currentValue ? 'ep-current' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return `<div class="${cls}" data-idx="${i}">
|
||||||
|
${item.icon ? `<span class="ep-item-icon">${item.icon}</span>` : ''}
|
||||||
|
<span class="ep-item-label">${item.label}</span>
|
||||||
|
${item.desc ? `<span class="ep-item-desc">${item.desc}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
this._list.querySelectorAll('.entity-palette-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
this._select(this._filtered[parseInt(el.dataset.idx)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll highlighted into view
|
||||||
|
const hl = this._list.querySelector('.ep-highlight');
|
||||||
|
if (hl) hl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
_onKeyDown(e) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._highlightIdx = Math.min(this._highlightIdx + 1, this._filtered.length - 1);
|
||||||
|
this._render();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._highlightIdx = Math.max(this._highlightIdx - 1, 0);
|
||||||
|
this._render();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this._filtered[this._highlightIdx]) {
|
||||||
|
this._select(this._filtered[this._highlightIdx]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
this._cancel();
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_select(item) {
|
||||||
|
this._overlay.classList.remove('open');
|
||||||
|
if (this._resolve) this._resolve(item.value);
|
||||||
|
this._resolve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancel() {
|
||||||
|
this._overlay.classList.remove('open');
|
||||||
|
if (this._resolve) this._resolve(undefined);
|
||||||
|
this._resolve = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EntitySelect (wrapper around a <select>) ────────────────
|
||||||
|
|
||||||
|
export class EntitySelect {
|
||||||
|
/**
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {HTMLSelectElement} opts.target - the <select> to enhance
|
||||||
|
* @param {Function} opts.getItems - () => Array<{value, label, icon?, desc?}>
|
||||||
|
* @param {string} [opts.placeholder] - palette search placeholder
|
||||||
|
* @param {Function} [opts.onChange] - called with (value) after selection
|
||||||
|
* @param {boolean} [opts.allowNone] - show a "None" entry at the top
|
||||||
|
* @param {string} [opts.noneLabel] - label for the None entry
|
||||||
|
*/
|
||||||
|
constructor({ target, getItems, placeholder, onChange, allowNone, noneLabel }) {
|
||||||
|
this._select = target;
|
||||||
|
this._getItems = getItems;
|
||||||
|
this._placeholder = placeholder || '';
|
||||||
|
this._onChange = onChange;
|
||||||
|
this._allowNone = allowNone || false;
|
||||||
|
this._noneLabel = noneLabel || '—';
|
||||||
|
this._items = getItems();
|
||||||
|
|
||||||
|
// Hide native select
|
||||||
|
this._select.style.display = 'none';
|
||||||
|
|
||||||
|
// Build trigger button
|
||||||
|
this._trigger = document.createElement('button');
|
||||||
|
this._trigger.type = 'button';
|
||||||
|
this._trigger.className = 'entity-select-trigger';
|
||||||
|
this._trigger.addEventListener('click', () => this._open());
|
||||||
|
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||||
|
|
||||||
|
this._syncTrigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _open() {
|
||||||
|
this._items = this._getItems();
|
||||||
|
const value = await EntityPalette.pick({
|
||||||
|
items: this._items,
|
||||||
|
current: this._select.value,
|
||||||
|
placeholder: this._placeholder,
|
||||||
|
allowNone: this._allowNone,
|
||||||
|
noneLabel: this._noneLabel,
|
||||||
|
});
|
||||||
|
if (value !== undefined) {
|
||||||
|
this._select.value = value;
|
||||||
|
this._syncTrigger();
|
||||||
|
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
if (this._onChange) this._onChange(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncTrigger() {
|
||||||
|
const val = this._select.value;
|
||||||
|
const item = this._items.find(i => i.value === val);
|
||||||
|
if (item) {
|
||||||
|
this._trigger.innerHTML =
|
||||||
|
`${item.icon ? `<span class="es-trigger-icon">${item.icon}</span>` : ''}` +
|
||||||
|
`<span class="es-trigger-label">${item.label}</span>` +
|
||||||
|
`<span class="es-trigger-arrow">▾</span>`;
|
||||||
|
} else if (this._allowNone && !val) {
|
||||||
|
this._trigger.innerHTML =
|
||||||
|
`<span class="es-trigger-label es-trigger-none">${this._noneLabel}</span>` +
|
||||||
|
`<span class="es-trigger-arrow">▾</span>`;
|
||||||
|
} else {
|
||||||
|
// Fallback: read from selected option text
|
||||||
|
const opt = this._select.selectedOptions[0];
|
||||||
|
const text = opt ? opt.textContent : val || '—';
|
||||||
|
this._trigger.innerHTML =
|
||||||
|
`<span class="es-trigger-label">${text}</span>` +
|
||||||
|
`<span class="es-trigger-arrow">▾</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the value programmatically (no change event). */
|
||||||
|
setValue(value) {
|
||||||
|
this._select.value = value;
|
||||||
|
this._syncTrigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh items and trigger display (call after repopulating the <select>). */
|
||||||
|
refresh() {
|
||||||
|
this._items = this._getItems();
|
||||||
|
this._syncTrigger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove enhancement, restore native <select>. */
|
||||||
|
destroy() {
|
||||||
|
this._trigger.remove();
|
||||||
|
this._select.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js';
|
|||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { ICON_MUSIC } from '../core/icons.js';
|
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
import { loadPictureSources } from './streams.js';
|
import { loadPictureSources } from './streams.js';
|
||||||
|
|
||||||
class AudioSourceModal extends Modal {
|
class AudioSourceModal extends Modal {
|
||||||
@@ -36,6 +37,11 @@ class AudioSourceModal extends Modal {
|
|||||||
|
|
||||||
const audioSourceModal = new AudioSourceModal();
|
const audioSourceModal = new AudioSourceModal();
|
||||||
|
|
||||||
|
// ── EntitySelect instances for audio source editor ──
|
||||||
|
let _asTemplateEntitySelect = null;
|
||||||
|
let _asDeviceEntitySelect = null;
|
||||||
|
let _asParentEntitySelect = null;
|
||||||
|
|
||||||
// ── Modal ─────────────────────────────────────────────────────
|
// ── Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function showAudioSourceModal(sourceType, editData) {
|
export async function showAudioSourceModal(sourceType, editData) {
|
||||||
@@ -242,6 +248,20 @@ function _filterDevicesBySelectedTemplate() {
|
|||||||
const match = Array.from(select.options).find(o => o.textContent === prevName);
|
const match = Array.from(select.options).find(o => o.textContent === prevName);
|
||||||
if (match) select.value = match.value;
|
if (match) select.value = match.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_asDeviceEntitySelect) _asDeviceEntitySelect.destroy();
|
||||||
|
if (devices.length > 0) {
|
||||||
|
_asDeviceEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => devices.map(d => ({
|
||||||
|
value: `${d.index}:${d.is_loopback ? '1' : '0'}`,
|
||||||
|
label: d.name,
|
||||||
|
icon: d.is_loopback ? ICON_AUDIO_LOOPBACK : ICON_AUDIO_INPUT,
|
||||||
|
desc: d.is_loopback ? 'Loopback' : 'Input',
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _selectAudioDevice(deviceIndex, isLoopback) {
|
function _selectAudioDevice(deviceIndex, isLoopback) {
|
||||||
@@ -259,6 +279,19 @@ function _loadMultichannelSources(selectedId) {
|
|||||||
select.innerHTML = multichannel.map(s =>
|
select.innerHTML = multichannel.map(s =>
|
||||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
if (_asParentEntitySelect) _asParentEntitySelect.destroy();
|
||||||
|
if (multichannel.length > 0) {
|
||||||
|
_asParentEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => multichannel.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getAudioSourceIcon('multichannel'),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _loadAudioTemplates(selectedId) {
|
function _loadAudioTemplates(selectedId) {
|
||||||
@@ -268,6 +301,20 @@ function _loadAudioTemplates(selectedId) {
|
|||||||
select.innerHTML = templates.map(t =>
|
select.innerHTML = templates.map(t =>
|
||||||
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
|
`<option value="${t.id}"${t.id === selectedId ? ' selected' : ''}>${escapeHtml(t.name)} (${t.engine_type.toUpperCase()})</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
if (_asTemplateEntitySelect) _asTemplateEntitySelect.destroy();
|
||||||
|
if (templates.length > 0) {
|
||||||
|
_asTemplateEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => templates.map(tmpl => ({
|
||||||
|
value: tmpl.id,
|
||||||
|
label: tmpl.name,
|
||||||
|
icon: ICON_AUDIO_TEMPLATE,
|
||||||
|
desc: tmpl.engine_type.toUpperCase(),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio Source Test (real-time spectrum) ────────────────────
|
// ── Audio Source Test (real-time spectrum) ────────────────────
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { t } from '../core/i18n.js';
|
|||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import {
|
import {
|
||||||
getColorStripIcon, getPictureSourceIcon,
|
getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon,
|
||||||
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
|
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
|
||||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
import { attachProcessPicker } from '../core/process-picker.js';
|
import { attachProcessPicker } from '../core/process-picker.js';
|
||||||
import { IconSelect } from '../core/icon-select.js';
|
import { IconSelect } from '../core/icon-select.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
|
|
||||||
class CSSEditorModal extends Modal {
|
class CSSEditorModal extends Modal {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -71,6 +72,11 @@ class CSSEditorModal extends Modal {
|
|||||||
|
|
||||||
const cssEditorModal = new CSSEditorModal();
|
const cssEditorModal = new CSSEditorModal();
|
||||||
|
|
||||||
|
// ── EntitySelect instances for CSS editor ──
|
||||||
|
let _cssPictureSourceEntitySelect = null;
|
||||||
|
let _cssAudioSourceEntitySelect = null;
|
||||||
|
let _cssClockEntitySelect = null;
|
||||||
|
|
||||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||||
|
|
||||||
const CSS_TYPE_KEYS = [
|
const CSS_TYPE_KEYS = [
|
||||||
@@ -181,6 +187,21 @@ function _populateClockDropdown(selectedId) {
|
|||||||
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
|
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
|
||||||
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
|
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
|
||||||
sel.value = prev || '';
|
sel.value = prev || '';
|
||||||
|
|
||||||
|
// Entity palette for clock
|
||||||
|
if (_cssClockEntitySelect) _cssClockEntitySelect.destroy();
|
||||||
|
_cssClockEntitySelect = new EntitySelect({
|
||||||
|
target: sel,
|
||||||
|
getItems: () => _cachedSyncClocks.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
icon: ICON_CLOCK,
|
||||||
|
desc: `${c.speed}x`,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
allowNone: true,
|
||||||
|
noneLabel: t('common.none'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onCSSClockChange() {
|
export function onCSSClockChange() {
|
||||||
@@ -581,6 +602,20 @@ async function _loadAudioSources() {
|
|||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
// Entity palette for audio source
|
||||||
|
if (_cssAudioSourceEntitySelect) _cssAudioSourceEntitySelect.destroy();
|
||||||
|
if (sources.length > 0) {
|
||||||
|
_cssAudioSourceEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => sources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getAudioSourceIcon(s.source_type),
|
||||||
|
desc: s.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
}
|
}
|
||||||
@@ -927,6 +962,18 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
|||||||
sourceSelect.appendChild(opt);
|
sourceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entity palette for picture source
|
||||||
|
if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.destroy();
|
||||||
|
_cssPictureSourceEntitySelect = new EntitySelect({
|
||||||
|
target: sourceSelect,
|
||||||
|
getItems: () => sources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getPictureSourceIcon(s.stream_type),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper: populate editor fields from a CSS source object
|
// Helper: populate editor fields from a CSS source object
|
||||||
const _populateFromCSS = async (css) => {
|
const _populateFromCSS = async (css) => {
|
||||||
const sourceType = css.source_type || 'picture';
|
const sourceType = css.source_type || 'picture';
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
|
|
||||||
|
let _patternBgEntitySelect = null;
|
||||||
|
|
||||||
class PatternTemplateModal extends Modal {
|
class PatternTemplateModal extends Modal {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -88,6 +91,20 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
|||||||
bgSelect.appendChild(opt);
|
bgSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entity palette for background source
|
||||||
|
if (_patternBgEntitySelect) _patternBgEntitySelect.destroy();
|
||||||
|
if (sources.length > 0) {
|
||||||
|
_patternBgEntitySelect = new EntitySelect({
|
||||||
|
target: bgSelect,
|
||||||
|
getItems: () => sources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getPictureSourceIcon(s.stream_type),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setPatternEditorBgImage(null);
|
setPatternEditorBgImage(null);
|
||||||
setPatternEditorSelectedIdx(-1);
|
setPatternEditorSelectedIdx(-1);
|
||||||
setPatternCanvasDragMode(null);
|
setPatternCanvasDragMode(null);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
import { IconSelect } from '../core/icon-select.js';
|
import { IconSelect } from '../core/icon-select.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
|
|
||||||
// ── 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' });
|
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||||
@@ -1617,6 +1618,11 @@ export async function editStream(streamId) {
|
|||||||
/** Track which engine type the stream-modal displays were loaded for. */
|
/** Track which engine type the stream-modal displays were loaded for. */
|
||||||
let _streamModalDisplaysEngine = null;
|
let _streamModalDisplaysEngine = null;
|
||||||
|
|
||||||
|
// ── EntitySelect instances for stream modal ──
|
||||||
|
let _captureTemplateEntitySelect = null;
|
||||||
|
let _sourceEntitySelect = null;
|
||||||
|
let _ppTemplateEntitySelect = null;
|
||||||
|
|
||||||
async function populateStreamModalDropdowns() {
|
async function populateStreamModalDropdowns() {
|
||||||
const [captureTemplates, streams, ppTemplates] = await Promise.all([
|
const [captureTemplates, streams, ppTemplates] = await Promise.all([
|
||||||
captureTemplatesCache.fetch().catch(() => []),
|
captureTemplatesCache.fetch().catch(() => []),
|
||||||
@@ -1672,6 +1678,44 @@ async function populateStreamModalDropdowns() {
|
|||||||
ppSelect.appendChild(opt);
|
ppSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entity palette selectors
|
||||||
|
if (_captureTemplateEntitySelect) _captureTemplateEntitySelect.destroy();
|
||||||
|
_captureTemplateEntitySelect = new EntitySelect({
|
||||||
|
target: templateSelect,
|
||||||
|
getItems: () => captureTemplates.map(tmpl => ({
|
||||||
|
value: tmpl.id,
|
||||||
|
label: tmpl.name,
|
||||||
|
icon: getEngineIcon(tmpl.engine_type),
|
||||||
|
desc: tmpl.engine_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_sourceEntitySelect) _sourceEntitySelect.destroy();
|
||||||
|
_sourceEntitySelect = new EntitySelect({
|
||||||
|
target: sourceSelect,
|
||||||
|
getItems: () => {
|
||||||
|
const editingId = document.getElementById('stream-id').value;
|
||||||
|
return streams.filter(s => s.id !== editingId).map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getPictureSourceIcon(s.stream_type),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_ppTemplateEntitySelect) _ppTemplateEntitySelect.destroy();
|
||||||
|
_ppTemplateEntitySelect = new EntitySelect({
|
||||||
|
target: ppSelect,
|
||||||
|
getItems: () => ppTemplates.map(tmpl => ({
|
||||||
|
value: tmpl.id,
|
||||||
|
label: tmpl.name,
|
||||||
|
icon: ICON_PP_TEMPLATE,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
_autoGenerateStreamName();
|
_autoGenerateStreamName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import { _splitOpenrgbZone } from './device-discovery.js';
|
|||||||
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||||
import { createColorStripCard } from './color-strips.js';
|
import { createColorStripCard } from './color-strips.js';
|
||||||
import {
|
import {
|
||||||
getValueSourceIcon, getTargetTypeIcon,
|
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
||||||
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,
|
||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||||
@@ -224,6 +225,11 @@ function _updateBrightnessThresholdVisibility() {
|
|||||||
document.getElementById('target-editor-brightness-threshold-group').style.display = '';
|
document.getElementById('target-editor-brightness-threshold-group').style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── EntitySelect instances for target editor ──
|
||||||
|
let _deviceEntitySelect = null;
|
||||||
|
let _cssEntitySelect = null;
|
||||||
|
let _brightnessVsEntitySelect = null;
|
||||||
|
|
||||||
function _populateCssDropdown(selectedId = '') {
|
function _populateCssDropdown(selectedId = '') {
|
||||||
const select = document.getElementById('target-editor-css-source');
|
const select = document.getElementById('target-editor-css-source');
|
||||||
select.innerHTML = _editorCssSources.map(s =>
|
select.innerHTML = _editorCssSources.map(s =>
|
||||||
@@ -235,12 +241,54 @@ function _populateBrightnessVsDropdown(selectedId = '') {
|
|||||||
const select = document.getElementById('target-editor-brightness-vs');
|
const select = document.getElementById('target-editor-brightness-vs');
|
||||||
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
||||||
_cachedValueSources.forEach(vs => {
|
_cachedValueSources.forEach(vs => {
|
||||||
const icon = getValueSourceIcon(vs.source_type);
|
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
|
||||||
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${icon} ${escapeHtml(vs.name)}</option>`;
|
|
||||||
});
|
});
|
||||||
select.innerHTML = html;
|
select.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _ensureTargetEntitySelects() {
|
||||||
|
// Device
|
||||||
|
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
|
||||||
|
_deviceEntitySelect = new EntitySelect({
|
||||||
|
target: document.getElementById('target-editor-device'),
|
||||||
|
getItems: () => _targetEditorDevices.map(d => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.name,
|
||||||
|
icon: getDeviceTypeIcon(d.device_type),
|
||||||
|
desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSS source
|
||||||
|
if (_cssEntitySelect) _cssEntitySelect.destroy();
|
||||||
|
_cssEntitySelect = new EntitySelect({
|
||||||
|
target: document.getElementById('target-editor-css-source'),
|
||||||
|
getItems: () => _editorCssSources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getColorStripIcon(s.source_type),
|
||||||
|
desc: s.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brightness value source
|
||||||
|
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
||||||
|
_brightnessVsEntitySelect = new EntitySelect({
|
||||||
|
target: document.getElementById('target-editor-brightness-vs'),
|
||||||
|
getItems: () => _cachedValueSources.map(vs => ({
|
||||||
|
value: vs.id,
|
||||||
|
label: vs.name,
|
||||||
|
icon: getValueSourceIcon(vs.source_type),
|
||||||
|
desc: vs.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
allowNone: true,
|
||||||
|
noneLabel: t('targets.brightness_vs.none'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||||
try {
|
try {
|
||||||
// Load devices, CSS sources, and value sources for dropdowns
|
// Load devices, CSS sources, and value sources for dropdowns
|
||||||
@@ -334,6 +382,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
|||||||
_populateBrightnessVsDropdown('');
|
_populateBrightnessVsDropdown('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entity palette selectors
|
||||||
|
_ensureTargetEntitySelects();
|
||||||
|
|
||||||
// Auto-name generation
|
// Auto-name generation
|
||||||
_targetNameManuallyEdited = !!(targetId || cloneData);
|
_targetNameManuallyEdited = !!(targetId || cloneData);
|
||||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||||
|
|||||||
@@ -16,17 +16,22 @@ import { t } from '../core/i18n.js';
|
|||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import {
|
import {
|
||||||
getValueSourceIcon,
|
getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon,
|
||||||
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
ICON_CLONE, ICON_EDIT, ICON_TEST,
|
||||||
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
|
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
|
||||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
|
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
|
||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
import { wrapCard } from '../core/card-colors.js';
|
import { wrapCard } from '../core/card-colors.js';
|
||||||
import { IconSelect } from '../core/icon-select.js';
|
import { IconSelect } from '../core/icon-select.js';
|
||||||
|
import { EntitySelect } from '../core/entity-palette.js';
|
||||||
import { loadPictureSources } from './streams.js';
|
import { loadPictureSources } from './streams.js';
|
||||||
|
|
||||||
export { getValueSourceIcon };
|
export { getValueSourceIcon };
|
||||||
|
|
||||||
|
// ── EntitySelect instances for value source editor ──
|
||||||
|
let _vsAudioSourceEntitySelect = null;
|
||||||
|
let _vsPictureSourceEntitySelect = null;
|
||||||
|
|
||||||
class ValueSourceModal extends Modal {
|
class ValueSourceModal extends Modal {
|
||||||
constructor() { super('value-source-modal'); }
|
constructor() { super('value-source-modal'); }
|
||||||
|
|
||||||
@@ -585,6 +590,20 @@ function _populateAudioSourceDropdown(selectedId) {
|
|||||||
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
|
const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]';
|
||||||
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
|
return `<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}${badge}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
if (_vsAudioSourceEntitySelect) _vsAudioSourceEntitySelect.destroy();
|
||||||
|
if (_cachedAudioSources.length > 0) {
|
||||||
|
_vsAudioSourceEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => _cachedAudioSources.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getAudioSourceIcon(s.source_type),
|
||||||
|
desc: s.source_type,
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adaptive helpers ──────────────────────────────────────────
|
// ── Adaptive helpers ──────────────────────────────────────────
|
||||||
@@ -595,6 +614,19 @@ function _populatePictureSourceDropdown(selectedId) {
|
|||||||
select.innerHTML = _cachedStreams.map(s =>
|
select.innerHTML = _cachedStreams.map(s =>
|
||||||
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
`<option value="${s.id}"${s.id === selectedId ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
|
if (_vsPictureSourceEntitySelect) _vsPictureSourceEntitySelect.destroy();
|
||||||
|
if (_cachedStreams.length > 0) {
|
||||||
|
_vsPictureSourceEntitySelect = new EntitySelect({
|
||||||
|
target: select,
|
||||||
|
getItems: () => _cachedStreams.map(s => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.name,
|
||||||
|
icon: getPictureSourceIcon(s.stream_type),
|
||||||
|
})),
|
||||||
|
placeholder: t('palette.search'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addSchedulePoint(time = '', value = 1.0) {
|
export function addSchedulePoint(time = '', value = 1.0) {
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.clone": "Clone",
|
"common.clone": "Clone",
|
||||||
"common.none": "None",
|
"common.none": "None",
|
||||||
|
"palette.search": "Search…",
|
||||||
"section.filter.placeholder": "Filter...",
|
"section.filter.placeholder": "Filter...",
|
||||||
"section.filter.reset": "Clear filter",
|
"section.filter.reset": "Clear filter",
|
||||||
"section.expand_all": "Expand all sections",
|
"section.expand_all": "Expand all sections",
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
"common.edit": "Редактировать",
|
"common.edit": "Редактировать",
|
||||||
"common.clone": "Клонировать",
|
"common.clone": "Клонировать",
|
||||||
"common.none": "Нет",
|
"common.none": "Нет",
|
||||||
|
"palette.search": "Поиск…",
|
||||||
"section.filter.placeholder": "Фильтр...",
|
"section.filter.placeholder": "Фильтр...",
|
||||||
"section.filter.reset": "Очистить фильтр",
|
"section.filter.reset": "Очистить фильтр",
|
||||||
"section.expand_all": "Развернуть все секции",
|
"section.expand_all": "Развернуть все секции",
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
"common.edit": "编辑",
|
"common.edit": "编辑",
|
||||||
"common.clone": "克隆",
|
"common.clone": "克隆",
|
||||||
"common.none": "无",
|
"common.none": "无",
|
||||||
|
"palette.search": "搜索…",
|
||||||
"section.filter.placeholder": "筛选...",
|
"section.filter.placeholder": "筛选...",
|
||||||
"section.filter.reset": "清除筛选",
|
"section.filter.reset": "清除筛选",
|
||||||
"section.expand_all": "全部展开",
|
"section.expand_all": "全部展开",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - Navigation: network-first with offline fallback
|
* - Navigation: network-first with offline fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'ledgrab-v16';
|
const CACHE_NAME = 'ledgrab-v17';
|
||||||
|
|
||||||
// Only pre-cache static assets (no auth required).
|
// Only pre-cache static assets (no auth required).
|
||||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||||
|
|||||||
Reference in New Issue
Block a user