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

@@ -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 = `<div class="stream-tab-bar">${subTabs.map(tab =>
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${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="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
// 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)}
</div>`;
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(`<button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_targetsTree.update(treeGroups, activeLeaf);
}
// Show/hide stop-all buttons based on running state