Replace flat sub-tab bars with tree sidebar navigation

Add TreeNav component that groups related entity types into a
collapsible hierarchy for Targets and Sources tabs. Targets tree
shows section-level leaves (Devices, Color Strips, LED Targets,
KC Targets, Pattern Templates) with scroll-to-section on click.
Sources tree groups into Picture, Audio, and Utility categories.

Also fixes missing csAudioTemplates in stream section expand/collapse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 12:03:31 +03:00
parent ee40d99067
commit 3915573514
10 changed files with 585 additions and 43 deletions

View File

@@ -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 = `<div class="stream-tab-bar">${tabs.map(tab =>
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} <span data-i18n="${tab.titleKey}">${t(tab.titleKey)}</span> <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
// 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 `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).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(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_streamsTree.update(treeGroups, activeTab);
}
}