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

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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 ? `<div class="tree-extra">${this._extraHtml}</div>` : '');
this._bindEvents(container);
}
_renderGroup(group, collapsed) {
const isCollapsed = !!collapsed[group.key];
const groupCount = group.children.reduce((sum, c) => sum + (c.count || 0), 0);
return `
<div class="tree-group" data-tree-group="${group.key}">
<div class="tree-group-header" data-tree-group-toggle="${group.key}">
<span class="tree-chevron${isCollapsed ? '' : ' open'}">&#9654;</span>
${group.icon ? `<span class="tree-node-icon">${group.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${group.titleKey}">${t(group.titleKey)}</span>
<span class="tree-group-count">${groupCount}</span>
</div>
<div class="tree-children${isCollapsed ? ' collapsed' : ''}">
${group.children.map(leaf => `
<div class="tree-leaf${leaf.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${leaf.key}">
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
<span class="tree-count">${leaf.count ?? 0}</span>
</div>
`).join('')}
</div>
</div>`;
}
_renderStandalone(leaf) {
return `
<div class="tree-standalone${leaf.key === this._activeLeaf ? ' active' : ''}" data-tree-leaf="${leaf.key}">
${leaf.icon ? `<span class="tree-node-icon">${leaf.icon}</span>` : ''}
<span class="tree-node-title" data-i18n="${leaf.titleKey}">${t(leaf.titleKey)}</span>
<span class="tree-count">${leaf.count ?? 0}</span>
</div>`;
}
_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));
});
});
}
}

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);
}
}

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

View File

@@ -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 = [

View File

@@ -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",

View File

@@ -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": "Редактировать источник значений",

View File

@@ -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": "编辑值源",

View File

@@ -23,6 +23,7 @@
<link rel="stylesheet" href="/static/css/streams.css">
<link rel="stylesheet" href="/static/css/patterns.css">
<link rel="stylesheet" href="/static/css/automations.css">
<link rel="stylesheet" href="/static/css/tree-nav.css">
<link rel="stylesheet" href="/static/css/tutorials.css">
<link rel="stylesheet" href="/static/css/mobile.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
@@ -109,14 +110,20 @@
</div>
<div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
<div id="targets-panel-content">
<div class="loading-spinner"></div>
<div class="tree-layout">
<nav class="tree-sidebar" id="targets-tree-nav"></nav>
<div class="tree-content" id="targets-panel-content">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-streams" role="tabpanel" aria-labelledby="tab-btn-streams">
<div id="streams-list">
<div class="loading-spinner"></div>
<div class="tree-layout">
<nav class="tree-sidebar" id="streams-tree-nav"></nav>
<div class="tree-content" id="streams-list">
<div class="loading-spinner"></div>
</div>
</div>
</div>