From f67936c977c17800bb064f7307bb06c83bbd057f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Feb 2026 02:40:24 +0300 Subject: [PATCH] Add WebUI navigation improvements: keyboard shortcuts, hash routing, command palette, cross-entity links - Keyboard shortcuts: Ctrl+1-4 for tab switching - URL hash routing: #tab/subtab format with browser back/forward support - Tab count badges: running targets and active profiles counts - Cross-entity quick links: clickable references navigate to related cards - Command palette (Ctrl+K): global search across all entities with keyboard navigation - Expand/collapse all sections: buttons in sub-tab bars - Sticky section headers: headers pin while scrolling long card grids - Improved section filter: better styling with reset button Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/static/css/layout.css | 142 +++++++++ .../wled_controller/static/css/patterns.css | 21 ++ .../wled_controller/static/css/streams.css | 78 ++++- server/src/wled_controller/static/js/app.js | 45 ++- .../static/js/core/card-sections.js | 77 ++++- .../static/js/core/command-palette.js | 294 ++++++++++++++++++ .../static/js/core/navigation.js | 66 ++++ .../static/js/features/dashboard.js | 4 +- .../static/js/features/kc-targets.js | 6 +- .../static/js/features/profiles.js | 3 + .../static/js/features/streams.js | 30 +- .../static/js/features/tabs.js | 95 +++++- .../static/js/features/targets.js | 30 +- .../wled_controller/static/locales/en.json | 20 +- .../wled_controller/static/locales/ru.json | 20 +- .../src/wled_controller/templates/index.html | 20 +- 16 files changed, 917 insertions(+), 34 deletions(-) create mode 100644 server/src/wled_controller/static/js/core/command-palette.js create mode 100644 server/src/wled_controller/static/js/core/navigation.js diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index e78c735..ee00824 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -144,6 +144,25 @@ h2 { border-bottom-color: var(--primary-color); } +.tab-badge { + background: var(--border-color); + color: var(--text-secondary); + font-size: 0.65rem; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + margin-left: 4px; + min-width: 16px; + text-align: center; + line-height: 1.3; + display: inline-block; +} + +.tab-btn.active .tab-badge { + background: var(--primary-color); + color: #fff; +} + .tab-panel { display: none; } @@ -200,6 +219,129 @@ h2 { text-decoration: underline; } +/* Command Palette */ +#command-palette { + position: fixed; + inset: 0; + z-index: 3000; + display: flex; + justify-content: center; + padding-top: 15vh; +} + +.cp-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} + +.cp-dialog { + position: relative; + width: 520px; + max-width: 90vw; + max-height: 60vh; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; + align-self: flex-start; +} + +.cp-input { + width: 100%; + padding: 14px 16px; + border: none; + border-bottom: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); + font-size: 1rem; + outline: none; + box-sizing: border-box; +} + +.cp-input::placeholder { + color: var(--text-secondary); +} + +.cp-results { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.cp-group-header { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + padding: 8px 16px 4px; +} + +.cp-result { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + cursor: pointer; + transition: background 0.1s; +} + +.cp-result:hover { + background: var(--bg-secondary); +} + +.cp-result.cp-active { + background: var(--primary-color); + color: #fff; +} + +.cp-result.cp-active .cp-detail { + color: rgba(255, 255, 255, 0.7); +} + +.cp-icon { + flex-shrink: 0; + width: 20px; + text-align: center; + font-size: 0.9rem; +} + +.cp-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; +} + +.cp-detail { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.cp-loading, +.cp-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.cp-footer { + padding: 6px 16px; + border-top: 1px solid var(--border-color); + font-size: 0.7rem; + color: var(--text-secondary); + text-align: center; +} + @media (max-width: 768px) { header { flex-direction: column; diff --git a/server/src/wled_controller/static/css/patterns.css b/server/src/wled_controller/static/css/patterns.css index 7ddbb5a..174ba0c 100644 --- a/server/src/wled_controller/static/css/patterns.css +++ b/server/src/wled_controller/static/css/patterns.css @@ -61,6 +61,27 @@ font-size: 0.7rem; } +.stream-card-link { + cursor: pointer; + text-decoration: none; + transition: background 0.2s, color 0.2s; +} + +.stream-card-link:hover { + background: var(--primary-color); + color: #fff; +} + +@keyframes cardHighlight { + 0%, 100% { box-shadow: none; } + 25%, 75% { box-shadow: 0 0 0 3px var(--primary-color); } +} + +.card-highlight, +.template-card.card-highlight { + animation: cardHighlight 2s ease-in-out; +} + /* Key Colors target styles */ .kc-rect-list { display: flex; diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 8518729..e342728 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -577,6 +577,33 @@ color: #fff; } +.cs-expand-collapse-group { + margin-left: auto; + display: flex; + gap: 2px; +} + +.btn-expand-collapse { + background: none; + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.85rem; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s; + padding: 0; +} + +.btn-expand-collapse:hover { + color: var(--text-color); + background: var(--border-color); +} + .stream-tab-panel { display: none; } @@ -603,6 +630,11 @@ border-bottom: 1px solid var(--border-color); } +.subtab-section-header.cs-header { + margin-bottom: 0; + padding-bottom: 8px; +} + /* ── Collapsible card sections (cs-*) ── */ .cs-header { @@ -611,6 +643,11 @@ gap: 8px; cursor: pointer; user-select: none; + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-color); + padding: 8px 0; } .cs-chevron { @@ -635,22 +672,31 @@ flex-shrink: 0; } -.cs-filter { +.cs-filter-wrap { + position: relative; margin-left: auto; - width: 160px; + width: 180px; max-width: 40%; - padding: 3px 8px !important; + flex-shrink: 0; +} + +.cs-filter { + width: 100%; + padding: 4px 26px 4px 10px !important; font-size: 0.78rem !important; border: 1px solid var(--border-color) !important; - border-radius: 12px !important; - background: var(--bg-color) !important; + border-radius: 14px !important; + background: var(--bg-secondary) !important; color: var(--text-color) !important; outline: none; box-shadow: none !important; + box-sizing: border-box; + transition: border-color 0.2s, background 0.2s, width 0.2s; } .cs-filter:focus { border-color: var(--primary-color) !important; + background: var(--bg-color) !important; } .cs-filter::placeholder { @@ -658,6 +704,28 @@ font-size: 0.75rem; } +.cs-filter-reset { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + font-size: 1rem; + cursor: pointer; + padding: 0 5px; + line-height: 1; + border-radius: 50%; + transition: color 0.15s, background 0.15s; + display: none; +} + +.cs-filter-reset:hover { + color: var(--text-color); + background: var(--border-color); +} + /* Responsive adjustments */ @media (max-width: 768px) { .templates-grid { diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index ba692a7..3c073cf 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -54,6 +54,7 @@ import { addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption, renderModalFilterList, updateCaptureDuration, cloneStream, cloneCaptureTemplate, clonePPTemplate, + expandAllStreamSections, collapseAllStreamSections, } from './features/streams.js'; import { createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh, @@ -88,6 +89,7 @@ import { startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, toggleLedPreview, + expandAllTargetSections, collapseAllTargetSections, } from './features/targets.js'; // Layer 5: color-strip sources @@ -123,8 +125,10 @@ import { showCSSCalibration, toggleCalibrationOverlay, } from './features/calibration.js'; -// Layer 6: tabs -import { switchTab, initTabs, startAutoRefresh } from './features/tabs.js'; +// Layer 6: tabs, navigation, command palette +import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js'; +import { navigateToCard } from './core/navigation.js'; +import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js'; // ─── Register all HTML onclick / onchange / onfocus globals ─── @@ -191,6 +195,7 @@ Object.assign(window, { // streams / capture templates / PP templates loadPictureSources, switchStreamTab, + expandAllStreamSections, collapseAllStreamSections, showAddTemplateModal, editTemplate, closeTemplateModal, @@ -285,6 +290,7 @@ Object.assign(window, { loadTargetsTab, loadTargets, switchTargetSubTab, + expandAllTargetSections, collapseAllTargetSections, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, @@ -348,14 +354,38 @@ Object.assign(window, { showCSSCalibration, toggleCalibrationOverlay, - // tabs + // tabs / navigation / command palette switchTab, startAutoRefresh, + navigateToCard, + openCommandPalette, + closeCommandPalette, }); -// ─── Global Escape key handler ─── +// ─── Global keyboard shortcuts ─── document.addEventListener('keydown', (e) => { + const tag = document.activeElement?.tagName; + const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + + // Command palette: Ctrl+K / Cmd+K (works even in inputs) + if ((e.ctrlKey || e.metaKey) && e.key === 'k' && !e.altKey && !e.shiftKey) { + e.preventDefault(); + openCommandPalette(); + return; + } + + // Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) + if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { + const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams' }; + const tab = tabMap[e.key]; + if (tab) { + e.preventDefault(); + switchTab(tab); + return; + } + } + if (e.key === 'Escape') { // Close in order: overlay lightboxes first, then modals via stack if (document.getElementById('display-picker-lightbox').classList.contains('active')) { @@ -368,6 +398,10 @@ document.addEventListener('keydown', (e) => { } }); +// ─── Browser back/forward via hash routing ─── + +window.addEventListener('popstate', handlePopState); + // ─── Cleanup on page unload ─── window.addEventListener('beforeunload', () => { @@ -393,6 +427,9 @@ document.addEventListener('DOMContentLoaded', async () => { // Show content now that translations are loaded and tabs are set document.body.style.visibility = 'visible'; + // Initialize command palette + initCommandPalette(); + // Setup form handler document.getElementById('add-device-form').addEventListener('submit', handleAddDevice); diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index 404a707..ca8adfe 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -78,8 +78,11 @@ export class CardSection { ${chevron} ${t(this.titleKey)} ${count} - +
+ + +
${cardsHtml} @@ -96,15 +99,23 @@ export class CardSection { if (!header || !content) return; header.addEventListener('mousedown', (e) => { - if (e.target.closest('.cs-filter')) return; + if (e.target.closest('.cs-filter-wrap')) return; this._toggleCollapse(header, content); }); if (filterInput) { + const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`); + const updateResetVisibility = () => { + if (resetBtn) resetBtn.style.display = filterInput.value ? '' : 'none'; + }; + filterInput.addEventListener('mousedown', (e) => e.stopPropagation()); + if (resetBtn) resetBtn.addEventListener('mousedown', (e) => e.stopPropagation()); + let timer = null; filterInput.addEventListener('input', () => { clearTimeout(timer); + updateResetVisibility(); timer = setTimeout(() => { this._filterValue = filterInput.value.trim(); this._applyFilter(content, this._filterValue); @@ -117,15 +128,28 @@ export class CardSection { filterInput.value = ''; this._filterValue = ''; this._applyFilter(content, ''); + updateResetVisibility(); } } }); + if (resetBtn) { + resetBtn.addEventListener('click', (e) => { + e.stopPropagation(); + filterInput.value = ''; + this._filterValue = ''; + this._applyFilter(content, ''); + updateResetVisibility(); + filterInput.focus(); + }); + } + // Restore filter from before re-render if (this._filterValue) { filterInput.value = this._filterValue; this._applyFilter(content, this._filterValue); } + updateResetVisibility(); } // Tag card elements with their source HTML for future reconciliation @@ -205,6 +229,53 @@ export class CardSection { for (const s of sections) s.bind(); } + /** Expand all given sections. */ + static expandAll(sections) { + const map = _getCollapsedMap(); + for (const s of sections) { + map[s.sectionKey] = false; + const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`); + const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`); + if (content) content.style.display = ''; + if (header) { + const chevron = header.querySelector('.cs-chevron'); + if (chevron) chevron.textContent = '\u25BC'; + } + } + 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}"]`); + if (content) content.style.display = 'none'; + if (header) { + const chevron = header.querySelector('.cs-chevron'); + if (chevron) chevron.textContent = '\u25B6'; + } + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } + + /** Programmatically expand this section if collapsed. */ + expand() { + const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`); + const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`); + if (!header || !content) return; + const map = _getCollapsedMap(); + if (map[this.sectionKey]) { + map[this.sectionKey] = false; + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + content.style.display = ''; + const chevron = header.querySelector('.cs-chevron'); + if (chevron) chevron.textContent = '\u25BC'; + } + } + // ── private ── _tagCards(content) { diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js new file mode 100644 index 0000000..f89085c --- /dev/null +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -0,0 +1,294 @@ +/** + * Command Palette — global search & navigation (Ctrl+K / Cmd+K). + */ + +import { fetchWithAuth } from './api.js'; +import { t } from './i18n.js'; +import { navigateToCard } from './navigation.js'; + +let _isOpen = false; +let _items = []; +let _filtered = []; +let _selectedIdx = 0; +let _loading = false; + +// ─── Entity definitions: endpoint → palette items ─── + +const _streamSubTab = { + raw: { sub: 'raw', section: 'raw-streams' }, + processed: { sub: 'processed', section: 'proc-streams' }, + static_image: { sub: 'static_image', section: 'static-streams' }, +}; + +function _mapEntities(data, mapFn) { + if (Array.isArray(data)) return data.map(mapFn).filter(Boolean); + return []; +} + +function _buildItems(results) { + const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams] = results; + const items = []; + + _mapEntities(devices, d => items.push({ + name: d.name, detail: d.device_type, group: 'devices', icon: '🖥️', + nav: ['targets', 'led', 'led-devices', 'data-device-id', d.id], + })); + + _mapEntities(targets, tgt => { + if (tgt.target_type === 'key_colors') { + items.push({ + name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: '🎨', + nav: ['targets', 'key_colors', 'kc-targets', 'data-kc-target-id', tgt.id], + }); + } else { + items.push({ + name: tgt.name, detail: tgt.target_type, group: 'targets', icon: '⚡', + nav: ['targets', 'led', 'led-targets', 'data-target-id', tgt.id], + }); + } + }); + + _mapEntities(css, c => items.push({ + name: c.name, detail: c.css_type || c.source_type, group: 'css', icon: '🎨', + nav: ['targets', 'led', 'led-css', 'data-css-id', c.id], + })); + + _mapEntities(profiles, p => items.push({ + name: p.name, detail: p.enabled ? 'enabled' : '', group: 'profiles', icon: '📋', + nav: ['profiles', null, 'profiles', 'data-profile-id', p.id], + })); + + _mapEntities(capTempl, ct => items.push({ + name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: '📷', + nav: ['streams', 'raw', 'raw-templates', 'data-template-id', ct.id], + })); + + _mapEntities(ppTempl, pp => items.push({ + name: pp.name, detail: '', group: 'pp_templates', icon: '🔧', + nav: ['streams', 'processed', 'proc-templates', 'data-pp-template-id', pp.id], + })); + + _mapEntities(patTempl, pt => items.push({ + name: pt.name, detail: '', group: 'pattern_templates', icon: '🧩', + nav: ['targets', 'key_colors', 'kc-patterns', 'data-pattern-template-id', pt.id], + })); + + _mapEntities(audioSrc, a => { + const section = a.source_type === 'mono' ? 'audio-mono' : 'audio-multi'; + items.push({ + name: a.name, detail: a.source_type, group: 'audio', icon: '🎵', + nav: ['streams', 'audio', section, 'data-id', a.id], + }); + }); + + _mapEntities(valSrc, v => items.push({ + name: v.name, detail: v.source_type, group: 'value', icon: '🔢', + nav: ['streams', 'value', 'value-sources', 'data-id', v.id], + })); + + _mapEntities(streams, s => { + const mapping = _streamSubTab[s.stream_type] || _streamSubTab.raw; + const icon = s.stream_type === 'processed' ? '🎞️' : s.stream_type === 'static_image' ? '🖼️' : '🖥️'; + items.push({ + name: s.name, detail: s.stream_type, group: 'streams', icon, + nav: ['streams', mapping.sub, mapping.section, 'data-stream-id', s.id], + }); + }); + + return items; +} + +// Maps endpoint → response key that holds the array +const _responseKeys = [ + ['/devices', 'devices'], + ['/picture-targets', 'targets'], + ['/color-strip-sources', 'sources'], + ['/profiles', 'profiles'], + ['/capture-templates', 'templates'], + ['/postprocessing-templates','templates'], + ['/pattern-templates', 'templates'], + ['/audio-sources', 'sources'], + ['/value-sources', 'sources'], + ['/picture-sources', 'streams'], +]; + +async function _fetchAllEntities() { + const results = await Promise.all( + _responseKeys.map(([ep, key]) => + fetchWithAuth(ep, { retry: false, timeout: 5000 }) + .then(r => r.ok ? r.json() : {}) + .then(data => data[key] || []) + .catch(() => [])) + ); + return _buildItems(results); +} + +// ─── Filtering ─── + +function _filterItems(query) { + if (!query) return _items; + const lower = query.toLowerCase(); + const terms = lower.split(/\s+/).filter(Boolean); + return _items.filter(item => { + const text = `${item.name} ${item.detail} ${t('search.group.' + item.group)}`.toLowerCase(); + return terms.every(term => text.includes(term)); + }); +} + +// ─── Group ordering ─── + +const _groupOrder = [ + 'devices', 'targets', 'kc_targets', 'css', 'profiles', + 'streams', 'capture_templates', 'pp_templates', 'pattern_templates', + 'audio', 'value', +]; + +// ─── Rendering ─── + +function _render() { + const results = document.getElementById('cp-results'); + if (!results) return; + + if (_loading) { + results.innerHTML = `
${t('search.loading')}
`; + return; + } + + if (_filtered.length === 0) { + results.innerHTML = `
${t('search.no_results')}
`; + return; + } + + // Group items preserving order + const grouped = new Map(); + for (const g of _groupOrder) grouped.set(g, []); + for (const item of _filtered) { + if (!grouped.has(item.group)) grouped.set(item.group, []); + grouped.get(item.group).push(item); + } + + let html = ''; + let idx = 0; + for (const [group, items] of grouped) { + if (items.length === 0) continue; + html += `
${t('search.group.' + group)}
`; + for (const item of items) { + const active = idx === _selectedIdx ? ' cp-active' : ''; + html += `
` + + `${item.icon}` + + `${_escHtml(item.name)}` + + (item.detail ? `${_escHtml(item.detail)}` : '') + + `
`; + idx++; + } + } + results.innerHTML = html; + _scrollActive(results); +} + +function _scrollActive(container) { + const active = container.querySelector('.cp-active'); + if (active) active.scrollIntoView({ block: 'nearest' }); +} + +function _escHtml(text) { + if (!text) return ''; + const d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; +} + +// ─── Open / Close ─── + +export async function openCommandPalette() { + if (_isOpen) return; + _isOpen = true; + _selectedIdx = 0; + + const overlay = document.getElementById('command-palette'); + const input = document.getElementById('cp-input'); + overlay.style.display = ''; + input.value = ''; + input.placeholder = t('search.placeholder'); + _loading = true; + _render(); + input.focus(); + + try { + _items = await _fetchAllEntities(); + } catch { + _items = []; + } + _loading = false; + _filtered = _filterItems(input.value.trim()); + _render(); +} + +export function closeCommandPalette() { + if (!_isOpen) return; + _isOpen = false; + const overlay = document.getElementById('command-palette'); + overlay.style.display = 'none'; + _items = []; + _filtered = []; +} + +// ─── Event handlers ─── + +function _onInput() { + const input = document.getElementById('cp-input'); + _filtered = _filterItems(input.value.trim()); + _selectedIdx = 0; + _render(); +} + +function _onKeydown(e) { + if (!_isOpen) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + _selectedIdx = Math.min(_selectedIdx + 1, _filtered.length - 1); + _render(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + _selectedIdx = Math.max(_selectedIdx - 1, 0); + _render(); + } else if (e.key === 'Enter') { + e.preventDefault(); + _selectCurrent(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeCommandPalette(); + } +} + +function _onClick(e) { + const row = e.target.closest('.cp-result'); + if (row) { + _selectedIdx = parseInt(row.dataset.cpIdx, 10); + _selectCurrent(); + return; + } + if (e.target.classList.contains('cp-backdrop')) { + closeCommandPalette(); + } +} + +function _selectCurrent() { + if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return; + const item = _filtered[_selectedIdx]; + closeCommandPalette(); + navigateToCard(...item.nav); +} + +// ─── Initialization ─── + +export function initCommandPalette() { + const overlay = document.getElementById('command-palette'); + if (!overlay) return; + + const input = document.getElementById('cp-input'); + input.addEventListener('input', _onInput); + input.addEventListener('keydown', _onKeydown); + overlay.addEventListener('click', _onClick); +} diff --git a/server/src/wled_controller/static/js/core/navigation.js b/server/src/wled_controller/static/js/core/navigation.js new file mode 100644 index 0000000..8b37a0b --- /dev/null +++ b/server/src/wled_controller/static/js/core/navigation.js @@ -0,0 +1,66 @@ +/** + * Cross-entity navigation — navigate to a specific card on any tab/subtab. + */ + +import { switchTab } from '../features/tabs.js'; + +/** + * Navigate to a card on any tab/subtab, expanding the section and scrolling to it. + * + * @param {string} tab Main tab: 'dashboard' | 'profiles' | 'targets' | 'streams' + * @param {string|null} subTab Sub-tab key or null + * @param {string|null} sectionKey CardSection key to expand, or null + * @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id') + * @param {string} cardValue Value of the data attribute + */ +export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) { + // Push current location to history so browser back returns here + history.pushState(null, '', location.hash || '#'); + switchTab(tab); + + requestAnimationFrame(() => { + if (subTab) { + if (tab === 'targets' && typeof window.switchTargetSubTab === 'function') { + window.switchTargetSubTab(subTab); + } else if (tab === 'streams' && typeof window.switchStreamTab === 'function') { + window.switchStreamTab(subTab); + } + } + + // Expand section if collapsed + if (sectionKey) { + const content = document.querySelector(`[data-cs-content="${sectionKey}"]`); + const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`); + if (content && content.style.display === 'none') { + content.style.display = ''; + const chevron = header?.querySelector('.cs-chevron'); + if (chevron) chevron.textContent = '\u25BC'; + const map = JSON.parse(localStorage.getItem('sections_collapsed') || '{}'); + map[sectionKey] = false; + localStorage.setItem('sections_collapsed', JSON.stringify(map)); + } + } + + // Wait for card to appear in DOM (tab data may load async) + _waitForCard(cardAttr, cardValue, 3000).then(card => { + if (!card) return; + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + card.classList.add('card-highlight'); + setTimeout(() => card.classList.remove('card-highlight'), 2000); + }); + }); +} + +function _waitForCard(cardAttr, cardValue, timeout) { + return new Promise(resolve => { + const card = document.querySelector(`[${cardAttr}="${cardValue}"]`); + if (card) { resolve(card); return; } + + const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout); + const observer = new MutationObserver(() => { + const el = document.querySelector(`[${cardAttr}="${cardValue}"]`); + if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); +} diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 224bd1d..a3ae4c8 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -7,7 +7,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js' import { t } from '../core/i18n.js'; import { showToast, formatUptime } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; -import { startAutoRefresh } from './tabs.js'; +import { startAutoRefresh, updateTabBadge } from './tabs.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; @@ -338,6 +338,7 @@ export async function loadDashboard(forceFullRender = false) { const running = enriched.filter(t => t.state && t.state.processing); const stopped = enriched.filter(t => !t.state || !t.state.processing); + updateTabBadge('targets', running.length); // Check if we can do an in-place metrics update (same targets, not first load) const newRunningIds = running.map(t => t.id).sort().join(','); @@ -365,6 +366,7 @@ export async function loadDashboard(forceFullRender = false) { if (profiles.length > 0) { const activeProfiles = profiles.filter(p => p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active); + updateTabBadge('profiles', activeProfiles.length); const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); dynamicHtml += `
diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index c35d9bd..36ffcee 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -78,11 +78,11 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
- 📺 ${escapeHtml(sourceName)} - 📄 ${escapeHtml(patternName)} + 📺 ${escapeHtml(sourceName)} + 📄 ${escapeHtml(patternName)} ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} ⚡ ${kcSettings.fps ?? 10} - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} + ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
allStates[tgt.id]?.processing).map(tgt => tgt.id) ); set_profilesCache(data.profiles); + const activeCount = data.profiles.filter(p => p.is_active).length; + updateTabBadge('profiles', activeCount); renderProfiles(data.profiles, runningTargetIds); } catch (error) { if (error.isAuth) return; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index b3799df..c48cbe4 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -28,6 +28,7 @@ import { Modal } from '../core/modal.js'; import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; +import { updateSubTabHash } from './tabs.js'; import { createValueSourceCard } from './value-sources.js'; // ── Card section instances ── @@ -560,6 +561,25 @@ export function switchStreamTab(tabKey) { panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`) ); localStorage.setItem('activeStreamTab', tabKey); + updateSubTabHash('streams', tabKey); +} + +const _streamSectionMap = { + raw: [csRawStreams, csRawTemplates], + static_image: [csStaticStreams], + processed: [csProcStreams, csProcTemplates], + audio: [csAudioMulti, csAudioMono], + value: [csValueSources], +}; + +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) { @@ -580,19 +600,21 @@ function renderPictureSourcesList(streams) { detailsHtml = `
🖥️ ${stream.display_index ?? 0} ⚡ ${stream.target_fps ?? 30} - ${capTmplName ? `📋 ${capTmplName}` : ''} + ${capTmplName ? `📋 ${capTmplName}` : ''}
`; } else if (stream.stream_type === 'processed') { const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); + const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw'; + const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams'; let ppTmplName = ''; if (stream.postprocessing_template_id) { const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name); } detailsHtml = `
- 📺 ${sourceName} - ${ppTmplName ? `📋 ${ppTmplName}` : ''} + 📺 ${sourceName} + ${ppTmplName ? `📋 ${ppTmplName}` : ''}
`; } else if (stream.stream_type === 'static_image') { const src = stream.image_source || ''; @@ -695,7 +717,7 @@ function renderPictureSourcesList(streams) { const tabBar = `
${tabs.map(tab => `` - ).join('')}
`; + ).join('')}
`; const renderAudioSourceCard = (src) => { const isMono = src.source_type === 'mono'; diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 5bd9ba4..70f442f 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -1,10 +1,26 @@ /** - * Tab switching — switchTab, initTabs, startAutoRefresh. + * Tab switching — switchTab, initTabs, startAutoRefresh, hash routing. */ import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js'; -export function switchTab(name) { +/** Parse location.hash into {tab, subTab}. */ +export function parseHash() { + const hash = location.hash.replace(/^#/, ''); + if (!hash) return {}; + const [tab, subTab] = hash.split('/'); + return { tab, subTab }; +} + +/** Update the URL hash without triggering popstate. */ +function _setHash(tab, subTab) { + const hash = '#' + (subTab ? `${tab}/${subTab}` : tab); + history.replaceState(null, '', hash); +} + +let _suppressHashUpdate = false; + +export function switchTab(name, { updateHash = true } = {}) { document.querySelectorAll('.tab-btn').forEach(btn => { const isActive = btn.dataset.tab === name; btn.classList.toggle('active', isActive); @@ -12,6 +28,16 @@ export function switchTab(name) { }); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); + + if (updateHash && !_suppressHashUpdate) { + const subTab = name === 'targets' + ? (localStorage.getItem('activeTargetSubTab') || 'led') + : name === 'streams' + ? (localStorage.getItem('activeStreamTab') || 'raw') + : null; + _setHash(name, subTab); + } + if (name === 'dashboard') { // Use window.* to avoid circular imports with feature modules if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard(); @@ -30,13 +56,44 @@ export function switchTab(name) { } export function initTabs() { - let saved = localStorage.getItem('activeTab'); + // Hash takes priority over localStorage + const hashRoute = parseHash(); + let saved; + + if (hashRoute.tab && document.getElementById(`tab-${hashRoute.tab}`)) { + saved = hashRoute.tab; + // Pre-set sub-tab so the sub-tab switch functions pick it up + if (hashRoute.subTab) { + if (saved === 'targets') localStorage.setItem('activeTargetSubTab', hashRoute.subTab); + if (saved === 'streams') localStorage.setItem('activeStreamTab', hashRoute.subTab); + } + } else { + saved = localStorage.getItem('activeTab'); + } + // Migrate legacy 'devices' tab to 'targets' if (saved === 'devices') saved = 'targets'; if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard'; switchTab(saved); } +/** Update hash when sub-tab changes. Called from targets.js / streams.js. */ +export function updateSubTabHash(tab, subTab) { + _setHash(tab, subTab); +} + +/** Update the count badge on a main tab button. Hidden when count is 0. */ +export function updateTabBadge(tabName, count) { + const badge = document.getElementById(`tab-badge-${tabName}`); + if (!badge) return; + if (count > 0) { + badge.textContent = count; + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } +} + export function startAutoRefresh() { if (refreshInterval) { clearInterval(refreshInterval); @@ -56,3 +113,35 @@ export function startAutoRefresh() { } }, dashboardPollInterval)); } + +/** + * Handle browser back/forward via popstate. + * Called from app.js. + */ +export function handlePopState() { + const hashRoute = parseHash(); + if (!hashRoute.tab) return; + + const currentTab = localStorage.getItem('activeTab'); + _suppressHashUpdate = true; + + if (hashRoute.tab !== currentTab) { + switchTab(hashRoute.tab, { updateHash: false }); + } + + if (hashRoute.subTab) { + if (hashRoute.tab === 'targets') { + const currentSub = localStorage.getItem('activeTargetSubTab'); + if (hashRoute.subTab !== currentSub && typeof window.switchTargetSubTab === 'function') { + window.switchTargetSubTab(hashRoute.subTab); + } + } else if (hashRoute.tab === 'streams') { + const currentSub = localStorage.getItem('activeStreamTab'); + if (hashRoute.subTab !== currentSub && typeof window.switchStreamTab === 'function') { + window.switchStreamTab(hashRoute.subTab); + } + } + } + + _suppressHashUpdate = false; +} diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index d7ca6ed..7930635 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -19,6 +19,7 @@ import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from '. import { createColorStripCard } from './color-strips.js'; import { getValueSourceIcon } from './value-sources.js'; import { CardSection } from '../core/card-sections.js'; +import { updateSubTabHash, updateTabBadge } from './tabs.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) @@ -374,6 +375,23 @@ export function switchTargetSubTab(tabKey) { panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`) ); localStorage.setItem('activeTargetSubTab', tabKey); + updateSubTabHash('targets', tabKey); +} + +export function expandAllTargetSections() { + const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; + const sections = activeSubTab === 'key_colors' + ? [csKCTargets, csPatternTemplates] + : [csDevices, csColorStrips, csLedTargets]; + CardSection.expandAll(sections); +} + +export function collapseAllTargetSections() { + const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; + const sections = activeSubTab === 'key_colors' + ? [csKCTargets, csPatternTemplates] + : [csDevices, csColorStrips, csLedTargets]; + CardSection.collapseAll(sections); } let _loadTargetsLock = false; @@ -466,6 +484,10 @@ export async function loadTargetsTab() { const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); + // Update tab badge with running target count + const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length; + updateTabBadge('targets', runningCount); + // Backward compat: map stored "wled" sub-tab to "led" let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; if (activeSubTab === 'wled') activeSubTab = 'led'; @@ -477,7 +499,7 @@ export async function loadTargetsTab() { const tabBar = `
${subTabs.map(tab => `` - ).join('')}
`; + ).join('')}`; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); @@ -676,10 +698,10 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
- 💡 ${escapeHtml(deviceName)} + 💡 ${escapeHtml(deviceName)} ⚡ ${target.fps || 30} - 🎞️ ${cssSummary} - ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} + 🎞️ ${cssSummary} + ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''}
${isProcessing ? ` diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 2bd7758..a9a5992 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -253,6 +253,9 @@ "common.edit": "Edit", "common.clone": "Clone", "section.filter.placeholder": "Filter...", + "section.filter.reset": "Clear filter", + "section.expand_all": "Expand all sections", + "section.collapse_all": "Collapse all sections", "streams.title": "\uD83D\uDCFA Sources", "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.group.raw": "Screen Capture", @@ -849,5 +852,20 @@ "value_source.error.name_required": "Please enter a name", "targets.brightness_vs": "Brightness Source:", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", - "targets.brightness_vs.none": "None (device brightness)" + "targets.brightness_vs.none": "None (device brightness)", + + "search.placeholder": "Search entities... (Ctrl+K)", + "search.loading": "Loading...", + "search.no_results": "No results found", + "search.group.devices": "Devices", + "search.group.targets": "LED Targets", + "search.group.kc_targets": "Key Colors Targets", + "search.group.css": "Color Strip Sources", + "search.group.profiles": "Profiles", + "search.group.streams": "Picture Streams", + "search.group.capture_templates": "Capture Templates", + "search.group.pp_templates": "Post-Processing Templates", + "search.group.pattern_templates": "Pattern Templates", + "search.group.audio": "Audio Sources", + "search.group.value": "Value Sources" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index e70ea20..9809edf 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -253,6 +253,9 @@ "common.edit": "Редактировать", "common.clone": "Клонировать", "section.filter.placeholder": "Фильтр...", + "section.filter.reset": "Очистить фильтр", + "section.expand_all": "Развернуть все секции", + "section.collapse_all": "Свернуть все секции", "streams.title": "\uD83D\uDCFA Источники", "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.group.raw": "Захват Экрана", @@ -849,5 +852,20 @@ "value_source.error.name_required": "Введите название", "targets.brightness_vs": "Источник яркости:", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", - "targets.brightness_vs.none": "Нет (яркость устройства)" + "targets.brightness_vs.none": "Нет (яркость устройства)", + + "search.placeholder": "Поиск... (Ctrl+K)", + "search.loading": "Загрузка...", + "search.no_results": "Ничего не найдено", + "search.group.devices": "Устройства", + "search.group.targets": "LED-цели", + "search.group.kc_targets": "Цели Key Colors", + "search.group.css": "Источники цветных лент", + "search.group.profiles": "Профили", + "search.group.streams": "Потоки изображений", + "search.group.capture_templates": "Шаблоны захвата", + "search.group.pp_templates": "Шаблоны постобработки", + "search.group.pattern_templates": "Шаблоны паттернов", + "search.group.audio": "Аудиоисточники", + "search.group.value": "Источники значений" } diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 8186af9..aa44b9e 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -46,10 +46,10 @@
- - - - + + + +
@@ -79,7 +79,8 @@