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 += ``;
+ 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 =>
`${tab.icon} ${t(tab.titleKey)} ${tab.count} `
- ).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 =>
`${tab.icon} ${t(tab.titleKey)} ${tab.count} `
- ).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 @@
- 📊 Dashboard
- 📋 Profiles
- ⚡ Targets
- 📺 Sources
+ 📊 Dashboard
+ 📋 Profiles
+ ⚡ Targets
+ 📺 Sources
@@ -79,7 +79,8 @@