From 6fc0e20e1db1c4f2065f439eb2ba90ba0e7717f0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Mar 2026 00:17:44 +0300 Subject: [PATCH] Add command palette entity selector for all editor dropdowns Replace plain ) */ +.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; +} diff --git a/server/src/wled_controller/static/js/core/entity-palette.js b/server/src/wled_controller/static/js/core/entity-palette.js new file mode 100644 index 0000000..e8dde3c --- /dev/null +++ b/server/src/wled_controller/static/js/core/entity-palette.js @@ -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: '', desc: '192.168.1.1' }], + * current: 'abc', + * placeholder: 'Search devices...', + * }); + * // value = 'abc' (selected) or undefined (cancelled) + * + * // Wrapper (replaces a is hidden but stays in the DOM (value kept in sync). + * Call sel.refresh() after repopulating the + +
+ + `; + 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 = '
'; + 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 `
+ ${item.icon ? `${item.icon}` : ''} + ${item.label} + ${item.desc ? `${item.desc}` : ''} +
`; + }).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 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 ? `${item.icon}` : ''}` + + `${item.label}` + + ``; + } else if (this._allowNone && !val) { + this._trigger.innerHTML = + `${this._noneLabel}` + + ``; + } else { + // Fallback: read from selected option text + const opt = this._select.selectedOptions[0]; + const text = opt ? opt.textContent : val || '—'; + this._trigger.innerHTML = + `${text}` + + ``; + } + } + + /** Update the value programmatically (no change event). */ + setValue(value) { + this._select.value = value; + this._syncTrigger(); + } + + /** Refresh items and trigger display (call after repopulating the . */ + destroy() { + this._trigger.remove(); + this._select.style.display = ''; + } +} diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js index 1b53753..be9727c 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.js +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -15,7 +15,8 @@ import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.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'; class AudioSourceModal extends Modal { @@ -36,6 +37,11 @@ class AudioSourceModal extends Modal { const audioSourceModal = new AudioSourceModal(); +// ── EntitySelect instances for audio source editor ── +let _asTemplateEntitySelect = null; +let _asDeviceEntitySelect = null; +let _asParentEntitySelect = null; + // ── Modal ───────────────────────────────────────────────────── export async function showAudioSourceModal(sourceType, editData) { @@ -242,6 +248,20 @@ function _filterDevicesBySelectedTemplate() { const match = Array.from(select.options).find(o => o.textContent === prevName); 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) { @@ -259,6 +279,19 @@ function _loadMultichannelSources(selectedId) { select.innerHTML = multichannel.map(s => `` ).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) { @@ -268,6 +301,20 @@ function _loadAudioTemplates(selectedId) { select.innerHTML = templates.map(t => `` ).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) ──────────────────── diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 2128c7b..2a2d7a2 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -8,7 +8,7 @@ import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { - getColorStripIcon, getPictureSourceIcon, + getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, @@ -17,6 +17,7 @@ import { import { wrapCard } from '../core/card-colors.js'; import { attachProcessPicker } from '../core/process-picker.js'; import { IconSelect } from '../core/icon-select.js'; +import { EntitySelect } from '../core/entity-palette.js'; class CSSEditorModal extends Modal { constructor() { @@ -71,6 +72,11 @@ class CSSEditorModal extends Modal { const cssEditorModal = new CSSEditorModal(); +// ── EntitySelect instances for CSS editor ── +let _cssPictureSourceEntitySelect = null; +let _cssAudioSourceEntitySelect = null; +let _cssClockEntitySelect = null; + /* ── Icon-grid type selector ──────────────────────────────────── */ const CSS_TYPE_KEYS = [ @@ -181,6 +187,21 @@ function _populateClockDropdown(selectedId) { sel.innerHTML = `` + _cachedSyncClocks.map(c => ``).join(''); 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() { @@ -581,6 +602,20 @@ async function _loadAudioSources() { if (sources.length === 0) { 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 { select.innerHTML = ''; } @@ -927,6 +962,18 @@ export async function showCSSEditor(cssId = null, cloneData = null) { 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 const _populateFromCSS = async (css) => { const sourceType = css.source_type || 'picture'; diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js index f7ade7c..5d40f11 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.js +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -21,6 +21,9 @@ import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { EntitySelect } from '../core/entity-palette.js'; + +let _patternBgEntitySelect = null; class PatternTemplateModal extends Modal { constructor() { @@ -88,6 +91,20 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n 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); setPatternEditorSelectedIdx(-1); setPatternCanvasDragMode(null); diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 08c7ea0..f52a4ab 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -49,6 +49,7 @@ import { } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; import { IconSelect } from '../core/icon-select.js'; +import { EntitySelect } from '../core/entity-palette.js'; // ── Card section instances ── 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. */ let _streamModalDisplaysEngine = null; +// ── EntitySelect instances for stream modal ── +let _captureTemplateEntitySelect = null; +let _sourceEntitySelect = null; +let _ppTemplateEntitySelect = null; + async function populateStreamModalDropdowns() { const [captureTemplates, streams, ppTemplates] = await Promise.all([ captureTemplatesCache.fetch().catch(() => []), @@ -1672,6 +1678,44 @@ async function populateStreamModalDropdowns() { 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(); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 7310c6a..9b05946 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -20,11 +20,12 @@ import { _splitOpenrgbZone } from './device-discovery.js'; import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createColorStripCard } from './color-strips.js'; import { - getValueSourceIcon, getTargetTypeIcon, + getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon, ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP, ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW, ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP, } from '../core/icons.js'; +import { EntitySelect } from '../core/entity-palette.js'; import { wrapCard } from '../core/card-colors.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js'; @@ -224,6 +225,11 @@ function _updateBrightnessThresholdVisibility() { 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 = '') { const select = document.getElementById('target-editor-css-source'); select.innerHTML = _editorCssSources.map(s => @@ -235,12 +241,54 @@ function _populateBrightnessVsDropdown(selectedId = '') { const select = document.getElementById('target-editor-brightness-vs'); let html = ``; _cachedValueSources.forEach(vs => { - const icon = getValueSourceIcon(vs.source_type); - html += ``; + 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) { try { // Load devices, CSS sources, and value sources for dropdowns @@ -334,6 +382,9 @@ export async function showTargetEditor(targetId = null, cloneData = null) { _populateBrightnessVsDropdown(''); } + // Entity palette selectors + _ensureTargetEntitySelects(); + // Auto-name generation _targetNameManuallyEdited = !!(targetId || cloneData); document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index e75349e..8dcac8e 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -16,17 +16,22 @@ import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { - getValueSourceIcon, + getValueSourceIcon, getAudioSourceIcon, getPictureSourceIcon, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; import { IconSelect } from '../core/icon-select.js'; +import { EntitySelect } from '../core/entity-palette.js'; import { loadPictureSources } from './streams.js'; export { getValueSourceIcon }; +// ── EntitySelect instances for value source editor ── +let _vsAudioSourceEntitySelect = null; +let _vsPictureSourceEntitySelect = null; + class ValueSourceModal extends Modal { constructor() { super('value-source-modal'); } @@ -585,6 +590,20 @@ function _populateAudioSourceDropdown(selectedId) { const badge = s.source_type === 'multichannel' ? ' [multichannel]' : ' [mono]'; return ``; }).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 ────────────────────────────────────────── @@ -595,6 +614,19 @@ function _populatePictureSourceDropdown(selectedId) { select.innerHTML = _cachedStreams.map(s => `` ).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) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8dfa3ac..925fce4 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -323,6 +323,7 @@ "common.edit": "Edit", "common.clone": "Clone", "common.none": "None", + "palette.search": "Search…", "section.filter.placeholder": "Filter...", "section.filter.reset": "Clear filter", "section.expand_all": "Expand all sections", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index f2aed31..683f3fd 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -323,6 +323,7 @@ "common.edit": "Редактировать", "common.clone": "Клонировать", "common.none": "Нет", + "palette.search": "Поиск…", "section.filter.placeholder": "Фильтр...", "section.filter.reset": "Очистить фильтр", "section.expand_all": "Развернуть все секции", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index a50e920..dd8ba26 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -323,6 +323,7 @@ "common.edit": "编辑", "common.clone": "克隆", "common.none": "无", + "palette.search": "搜索…", "section.filter.placeholder": "筛选...", "section.filter.reset": "清除筛选", "section.expand_all": "全部展开", diff --git a/server/src/wled_controller/static/sw.js b/server/src/wled_controller/static/sw.js index 188f51f..b4111d2 100644 --- a/server/src/wled_controller/static/sw.js +++ b/server/src/wled_controller/static/sw.js @@ -7,7 +7,7 @@ * - Navigation: network-first with offline fallback */ -const CACHE_NAME = 'ledgrab-v16'; +const CACHE_NAME = 'ledgrab-v17'; // Only pre-cache static assets (no auth required). // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.