diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css
index eeac03b..cad4b34 100644
--- a/server/src/wled_controller/static/css/streams.css
+++ b/server/src/wled_controller/static/css/streams.css
@@ -700,6 +700,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
/* Sub-tab content sections */
.subtab-section {
margin-bottom: 24px;
+ scroll-margin-top: var(--header-height, 48px);
}
.subtab-section:last-child {
diff --git a/server/src/wled_controller/static/css/tree-nav.css b/server/src/wled_controller/static/css/tree-nav.css
new file mode 100644
index 0000000..8b1abb0
--- /dev/null
+++ b/server/src/wled_controller/static/css/tree-nav.css
@@ -0,0 +1,255 @@
+/* ===========================
+ Tree Sidebar Navigation
+ =========================== */
+
+.tree-layout {
+ display: flex;
+ gap: 20px;
+ align-items: flex-start;
+}
+
+.tree-sidebar {
+ width: 210px;
+ min-width: 210px;
+ flex-shrink: 0;
+ position: sticky;
+ top: calc(var(--header-height, 48px) + 52px);
+ max-height: calc(100vh - var(--header-height, 48px) - 80px);
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.tree-content {
+ flex: 1;
+ min-width: 0;
+}
+
+/* ── Group ── */
+
+.tree-group {
+ margin-bottom: 2px;
+}
+
+.tree-group-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ cursor: pointer;
+ user-select: none;
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ border-radius: 6px;
+ transition: color 0.15s, background 0.15s;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.tree-group-header:hover {
+ color: var(--text-color);
+ background: var(--bg-secondary);
+}
+
+.tree-chevron {
+ font-size: 0.5rem;
+ width: 10px;
+ display: inline-block;
+ flex-shrink: 0;
+ transition: transform 0.2s ease;
+ color: var(--text-secondary);
+}
+
+.tree-chevron.open {
+ transform: rotate(90deg);
+}
+
+.tree-node-icon {
+ flex-shrink: 0;
+ line-height: 1;
+}
+
+.tree-node-icon .icon {
+ width: 14px;
+ height: 14px;
+}
+
+.tree-node-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tree-group-count {
+ background: var(--border-color);
+ color: var(--text-secondary);
+ font-size: 0.6rem;
+ font-weight: 600;
+ padding: 0 5px;
+ border-radius: 8px;
+ flex-shrink: 0;
+ min-width: 16px;
+ text-align: center;
+}
+
+/* ── Children (leaves) ── */
+
+.tree-children {
+ overflow: hidden;
+}
+
+.tree-children.collapsed {
+ display: none;
+}
+
+.tree-leaf {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 10px 5px 26px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ border-radius: 6px;
+ margin: 1px 0;
+ transition: color 0.15s, background 0.15s;
+}
+
+.tree-leaf:hover {
+ color: var(--text-color);
+ background: var(--bg-secondary);
+}
+
+.tree-leaf.active {
+ color: var(--primary-text-color);
+ background: color-mix(in srgb, var(--primary-color) 12%, transparent);
+ font-weight: 600;
+}
+
+.tree-leaf.active .tree-count {
+ background: var(--primary-color);
+ color: var(--primary-contrast);
+}
+
+/* ── Standalone leaf (top-level, no group) ── */
+
+.tree-standalone {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ border-radius: 6px;
+ margin: 1px 0;
+ transition: color 0.15s, background 0.15s;
+}
+
+.tree-standalone:hover {
+ color: var(--text-color);
+ background: var(--bg-secondary);
+}
+
+.tree-standalone.active {
+ color: var(--primary-text-color);
+ background: color-mix(in srgb, var(--primary-color) 12%, transparent);
+ font-weight: 600;
+}
+
+.tree-standalone.active .tree-count {
+ background: var(--primary-color);
+ color: var(--primary-contrast);
+}
+
+/* ── Count badge ── */
+
+.tree-count {
+ background: var(--border-color);
+ color: var(--text-secondary);
+ font-size: 0.6rem;
+ font-weight: 600;
+ padding: 0 5px;
+ border-radius: 8px;
+ flex-shrink: 0;
+ min-width: 16px;
+ text-align: center;
+}
+
+/* ── Extra (expand/collapse, tutorial buttons) ── */
+
+.tree-extra {
+ padding: 8px 10px;
+ margin-top: 4px;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+/* ── Responsive: stack on narrow screens ── */
+
+@media (max-width: 900px) {
+ .tree-layout {
+ flex-direction: column;
+ gap: 0;
+ }
+
+ .tree-sidebar {
+ width: 100%;
+ min-width: unset;
+ position: static;
+ max-height: none;
+ overflow-y: visible;
+ padding: 0 0 8px 0;
+ margin-bottom: 8px;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 2px;
+ }
+
+ .tree-group {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 2px;
+ margin-bottom: 0;
+ }
+
+ .tree-group-header {
+ padding: 4px 8px;
+ text-transform: none;
+ letter-spacing: normal;
+ }
+
+ .tree-children {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ }
+
+ .tree-children.collapsed {
+ display: none;
+ }
+
+ .tree-leaf {
+ padding: 4px 10px;
+ margin: 0;
+ }
+
+ .tree-standalone {
+ padding: 4px 10px;
+ }
+
+ .tree-extra {
+ margin-top: 0;
+ border-top: none;
+ padding: 4px;
+ margin-left: auto;
+ }
+}
diff --git a/server/src/wled_controller/static/js/core/tree-nav.js b/server/src/wled_controller/static/js/core/tree-nav.js
new file mode 100644
index 0000000..1751374
--- /dev/null
+++ b/server/src/wled_controller/static/js/core/tree-nav.js
@@ -0,0 +1,198 @@
+/**
+ * TreeNav — hierarchical sidebar navigation for Targets and Sources tabs.
+ * Replaces flat sub-tab bars with a collapsible tree that groups related items.
+ *
+ * Config format:
+ * [
+ * { key, titleKey, icon?, children: [{ key, titleKey, icon?, count, subTab?, sectionKey? }] },
+ * { key, titleKey, icon?, count } // standalone leaf (no children)
+ * ]
+ */
+
+import { t } from './i18n.js';
+
+const STORAGE_KEY = 'tree_nav_collapsed';
+
+function _getCollapsedMap() {
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
+ catch { return {}; }
+}
+
+function _saveCollapsed(key, collapsed) {
+ const map = _getCollapsedMap();
+ if (collapsed) map[key] = true;
+ else delete map[key];
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+}
+
+export class TreeNav {
+ /**
+ * @param {string} containerId - ID of the nav element to render into
+ * @param {object} opts
+ * @param {function} opts.onSelect - callback(leafKey, leafData) when a leaf is clicked
+ */
+ constructor(containerId, { onSelect }) {
+ this.containerId = containerId;
+ this.onSelect = onSelect;
+ this._items = [];
+ this._leafMap = new Map(); // key → leaf config
+ this._activeLeaf = null;
+ this._extraHtml = '';
+ }
+
+ /**
+ * Full re-render of the tree.
+ * @param {Array} items - tree structure (groups and/or standalone leaves)
+ * @param {string} activeLeafKey
+ */
+ update(items, activeLeafKey) {
+ this._items = items;
+ this._activeLeaf = activeLeafKey;
+ this._buildLeafMap();
+ this._render();
+ }
+
+ /** Update only the counts without full re-render. */
+ updateCounts(countMap) {
+ const container = document.getElementById(this.containerId);
+ if (!container) return;
+ for (const [key, count] of Object.entries(countMap)) {
+ const el = container.querySelector(`[data-tree-leaf="${key}"] .tree-count`);
+ if (el) el.textContent = count;
+ // Also update in-memory
+ const leaf = this._leafMap.get(key);
+ if (leaf) leaf.count = count;
+ }
+ // Update group counts
+ container.querySelectorAll('[data-tree-group]').forEach(groupEl => {
+ let total = 0;
+ groupEl.querySelectorAll('.tree-leaf .tree-count').forEach(cnt => {
+ total += parseInt(cnt.textContent, 10) || 0;
+ });
+ const groupCount = groupEl.querySelector('.tree-group-count');
+ if (groupCount) groupCount.textContent = total;
+ });
+ }
+
+ /** Set extra HTML appended at the bottom (expand/collapse buttons, etc.) */
+ setExtraHtml(html) {
+ this._extraHtml = html;
+ }
+
+ /** Highlight a specific leaf. */
+ setActive(leafKey) {
+ this._activeLeaf = leafKey;
+ const container = document.getElementById(this.containerId);
+ if (!container) return;
+ container.querySelectorAll('.tree-leaf').forEach(el =>
+ el.classList.toggle('active', el.dataset.treeLeaf === leafKey)
+ );
+ // Also highlight standalone leaves
+ container.querySelectorAll('.tree-standalone').forEach(el =>
+ el.classList.toggle('active', el.dataset.treeLeaf === leafKey)
+ );
+ }
+
+ /** Get leaf data for a key. */
+ getLeaf(key) {
+ return this._leafMap.get(key);
+ }
+
+ /** Find the first leaf key whose subTab matches. */
+ getLeafForSubTab(subTab) {
+ for (const [key, leaf] of this._leafMap) {
+ if ((leaf.subTab || key) === subTab) return key;
+ }
+ return null;
+ }
+
+ _buildLeafMap() {
+ this._leafMap.clear();
+ for (const item of this._items) {
+ if (item.children) {
+ for (const child of item.children) {
+ this._leafMap.set(child.key, child);
+ }
+ } else {
+ this._leafMap.set(item.key, item);
+ }
+ }
+ }
+
+ _render() {
+ const container = document.getElementById(this.containerId);
+ if (!container) return;
+
+ const collapsed = _getCollapsedMap();
+
+ const html = this._items.map(item => {
+ if (item.children) {
+ return this._renderGroup(item, collapsed);
+ }
+ return this._renderStandalone(item);
+ }).join('');
+
+ container.innerHTML = html +
+ (this._extraHtml ? `
` : '');
+ this._bindEvents(container);
+ }
+
+ _renderGroup(group, collapsed) {
+ const isCollapsed = !!collapsed[group.key];
+ const groupCount = group.children.reduce((sum, c) => sum + (c.count || 0), 0);
+
+ return `
+
+
+
+ ${group.children.map(leaf => `
+
+ ${leaf.icon ? `${leaf.icon}` : ''}
+ ${t(leaf.titleKey)}
+ ${leaf.count ?? 0}
+
+ `).join('')}
+
+
`;
+ }
+
+ _renderStandalone(leaf) {
+ return `
+
+ ${leaf.icon ? `${leaf.icon}` : ''}
+ ${t(leaf.titleKey)}
+ ${leaf.count ?? 0}
+
`;
+ }
+
+ _bindEvents(container) {
+ // Group header toggle
+ container.querySelectorAll('.tree-group-header').forEach(header => {
+ header.addEventListener('click', () => {
+ const key = header.dataset.treeGroupToggle;
+ const children = header.nextElementSibling;
+ if (!children) return;
+ const isNowCollapsed = children.classList.toggle('collapsed');
+ const chevron = header.querySelector('.tree-chevron');
+ if (chevron) chevron.classList.toggle('open', !isNowCollapsed);
+ _saveCollapsed(key, isNowCollapsed);
+ });
+ });
+
+ // Leaf click (both grouped and standalone)
+ container.querySelectorAll('[data-tree-leaf]').forEach(el => {
+ el.addEventListener('click', (e) => {
+ // Don't trigger on group header clicks
+ if (el.closest('.tree-group-header')) return;
+ const key = el.dataset.treeLeaf;
+ this.setActive(key);
+ if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
+ });
+ });
+ }
+}
diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js
index 951d325..7f8e7c3 100644
--- a/server/src/wled_controller/static/js/features/streams.js
+++ b/server/src/wled_controller/static/js/features/streams.js
@@ -37,6 +37,7 @@ import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
+import { TreeNav } from '../core/tree-nav.js';
import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js';
import { createSyncClockCard } from './sync-clocks.js';
@@ -1189,22 +1190,33 @@ export async function loadPictureSources() {
}
}
+let _streamsTreeTriggered = false;
+
+const _streamsTree = new TreeNav('streams-tree-nav', {
+ onSelect: (key) => {
+ _streamsTreeTriggered = true;
+ switchStreamTab(key);
+ _streamsTreeTriggered = false;
+ }
+});
+
export function switchStreamTab(tabKey) {
- document.querySelectorAll('.stream-tab-btn[data-stream-tab]').forEach(btn =>
- btn.classList.toggle('active', btn.dataset.streamTab === tabKey)
- );
document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel =>
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
localStorage.setItem('activeStreamTab', tabKey);
updateSubTabHash('streams', tabKey);
+ // Update tree active state (unless the tree triggered this switch)
+ if (!_streamsTreeTriggered) {
+ _streamsTree.setActive(tabKey);
+ }
}
const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
- audio: [csAudioMulti, csAudioMono],
+ audio: [csAudioMulti, csAudioMono, csAudioTemplates],
value: [csValueSources],
sync: [csSyncClocks],
};
@@ -1365,9 +1377,28 @@ function renderPictureSourcesList(streams) {
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
];
- const tabBar = `${tabs.map(tab =>
- ``
- ).join('')}
`;
+ // Build tree navigation structure
+ const treeGroups = [
+ {
+ key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
+ children: [
+ { key: 'raw', titleKey: 'streams.group.raw', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
+ { key: 'static_image', titleKey: 'streams.group.static_image', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length },
+ { key: 'processed', titleKey: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
+ ]
+ },
+ {
+ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
+ count: _cachedAudioSources.length + _cachedAudioTemplates.length,
+ },
+ {
+ key: 'utility_group', icon: ICON_WRENCH, titleKey: 'tree.group.utility',
+ children: [
+ { key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
+ { key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
+ ]
+ }
+ ];
const renderAudioSourceCard = (src) => {
const isMono = src.source_type === 'mono';
@@ -1470,9 +1501,13 @@ function renderPictureSourcesList(streams) {
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
- tabs.forEach(tab => {
- const btn = container.querySelector(`.stream-tab-btn[data-stream-tab="${tab.key}"] .stream-tab-count`);
- if (btn) btn.textContent = tab.count;
+ _streamsTree.updateCounts({
+ raw: rawStreams.length,
+ static_image: staticImageStreams.length,
+ processed: processedStreams.length,
+ audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
+ value: _cachedValueSources.length,
+ sync: _cachedSyncClocks.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -1497,8 +1532,12 @@ function renderPictureSourcesList(streams) {
return `${panelContent}
`;
}).join('');
- container.innerHTML = tabBar + panels;
+ container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
+
+ // Render tree sidebar with expand/collapse buttons
+ _streamsTree.setExtraHtml(``);
+ _streamsTree.update(treeGroups, activeTab);
}
}
diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js
index cace13e..edbb07f 100644
--- a/server/src/wled_controller/static/js/features/targets.js
+++ b/server/src/wled_controller/static/js/features/targets.js
@@ -25,12 +25,13 @@ import {
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,
- ICON_WARNING, ICON_PALETTE, ICON_WRENCH,
+ ICON_WARNING, ICON_PALETTE, ICON_WRENCH, ICON_TEMPLATE,
} from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { CardSection } from '../core/card-sections.js';
+import { TreeNav } from '../core/tree-nav.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
@@ -133,13 +134,6 @@ function _updateTargetFpsChart(targetId, fpsTarget) {
chart.update('none');
}
-function _updateSubTabCounts(subTabs) {
- subTabs.forEach(tab => {
- const btn = document.querySelector(`.target-sub-tab-btn[data-target-sub-tab="${tab.key}"] .stream-tab-count`);
- if (btn) btn.textContent = tab.count;
- });
-}
-
// --- Editor state ---
let _editorCssSources = []; // populated when editor opens
let _targetTagsInput = null;
@@ -522,15 +516,35 @@ export async function saveTargetEditor() {
// ===== TARGETS TAB (WLED devices + targets combined) =====
+let _treeTriggered = false;
+
+const _targetsTree = new TreeNav('targets-tree-nav', {
+ onSelect: (key, leaf) => {
+ const subTab = leaf?.subTab || key;
+ _treeTriggered = true;
+ switchTargetSubTab(subTab);
+ _treeTriggered = false;
+ // Scroll to specific section
+ if (leaf?.sectionKey) {
+ requestAnimationFrame(() => {
+ const section = document.querySelector(`[data-card-section="${leaf.sectionKey}"]`);
+ if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+ }
+ }
+});
+
export function switchTargetSubTab(tabKey) {
- document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
- btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
- );
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
updateSubTabHash('targets', tabKey);
+ // Update tree active state (unless the tree triggered this switch)
+ if (!_treeTriggered) {
+ const leafKey = _targetsTree.getLeafForSubTab(tabKey);
+ if (leafKey) _targetsTree.setActive(leafKey);
+ }
}
export function expandAllTargetSections() {
@@ -631,14 +645,26 @@ export async function loadTargetsTab() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
- const subTabs = [
- { key: 'led', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
- { key: 'key_colors', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
+ // Build tree navigation structure
+ const treeGroups = [
+ {
+ key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led',
+ children: [
+ { key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length, subTab: 'led', sectionKey: 'led-devices' },
+ { key: 'led-css', titleKey: 'targets.section.color_strips', icon: getColorStripIcon('static'), count: Object.keys(colorStripSourceMap).length, subTab: 'led', sectionKey: 'led-css' },
+ { key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length, subTab: 'led', sectionKey: 'led-targets' },
+ ]
+ },
+ {
+ key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
+ children: [
+ { key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length, subTab: 'key_colors', sectionKey: 'kc-targets' },
+ { key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length, subTab: 'key_colors', sectionKey: 'kc-patterns' },
+ ]
+ }
];
-
- const tabBar = `${subTabs.map(tab =>
- ``
- ).join('')}
`;
+ // Determine which tree leaf is active
+ const activeLeaf = activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
@@ -655,7 +681,13 @@ export async function loadTargetsTab() {
if (csDevices.isMounted()) {
// ── Incremental update: reconcile cards in-place ──
- _updateSubTabCounts(subTabs);
+ _targetsTree.updateCounts({
+ 'led-devices': ledDevices.length,
+ 'led-css': Object.keys(colorStripSourceMap).length,
+ 'led-targets': ledTargets.length,
+ 'kc-targets': kcTargets.length,
+ 'kc-patterns': patternTemplates.length,
+ });
csDevices.reconcile(deviceItems);
csColorStrips.reconcile(cssItems);
const ledResult = csLedTargets.reconcile(ledTargetItems);
@@ -685,8 +717,12 @@ export async function loadTargetsTab() {
${csKCTargets.render(kcTargetItems)}
${csPatternTemplates.render(patternItems)}
`;
- container.innerHTML = tabBar + ledPanel + kcPanel;
+ container.innerHTML = ledPanel + kcPanel;
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
+
+ // Render tree sidebar with expand/collapse buttons
+ _targetsTree.setExtraHtml(``);
+ _targetsTree.update(treeGroups, activeLeaf);
}
// Show/hide stop-all buttons based on running state
diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js
index 975c158..702c117 100644
--- a/server/src/wled_controller/static/js/features/tutorials.js
+++ b/server/src/wled_controller/static/js/features/tutorials.js
@@ -43,21 +43,21 @@ const dashboardTutorialSteps = [
];
const targetsTutorialSteps = [
- { selector: '[data-target-sub-tab="led"]', textKey: 'tour.tgt.led_tab', position: 'bottom' },
+ { selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' },
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
{ selector: '[data-card-section="led-css"]', textKey: 'tour.tgt.css', position: 'bottom' },
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
- { selector: '[data-target-sub-tab="key_colors"]', textKey: 'tour.tgt.kc_tab', position: 'bottom' }
+ { selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' }
];
const sourcesTourSteps = [
- { selector: '[data-stream-tab="raw"]', textKey: 'tour.src.raw', position: 'bottom' },
+ { selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
- { selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' },
- { selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' },
- { selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' },
- { selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' },
- { selector: '[data-stream-tab="sync"]', textKey: 'tour.src.sync', position: 'bottom' }
+ { selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' },
+ { selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' },
+ { selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' },
+ { selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' },
+ { selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' }
];
const automationsTutorialSteps = [
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 968f749..3759e39 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -1083,6 +1083,8 @@
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
+ "tree.group.picture": "Picture",
+ "tree.group.utility": "Utility",
"value_source.group.title": "Value Sources",
"value_source.add": "Add Value Source",
"value_source.edit": "Edit Value Source",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index f1339c7..4a6989e 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -1083,6 +1083,8 @@
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
+ "tree.group.picture": "Изображения",
+ "tree.group.utility": "Утилиты",
"value_source.group.title": "Источники значений",
"value_source.add": "Добавить источник значений",
"value_source.edit": "Редактировать источник значений",
diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json
index 1330ca2..b6847d1 100644
--- a/server/src/wled_controller/static/locales/zh.json
+++ b/server/src/wled_controller/static/locales/zh.json
@@ -1083,6 +1083,8 @@
"audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
+ "tree.group.picture": "图片",
+ "tree.group.utility": "工具",
"value_source.group.title": "值源",
"value_source.add": "添加值源",
"value_source.edit": "编辑值源",
diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html
index ad62955..36a0e5e 100644
--- a/server/src/wled_controller/templates/index.html
+++ b/server/src/wled_controller/templates/index.html
@@ -23,6 +23,7 @@
+
@@ -109,14 +110,20 @@