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:
@@ -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 {
|
||||
|
||||
255
server/src/wled_controller/static/css/tree-nav.css
Normal file
255
server/src/wled_controller/static/css/tree-nav.css
Normal 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;
|
||||
}
|
||||
}
|
||||
198
server/src/wled_controller/static/js/core/tree-nav.js
Normal file
198
server/src/wled_controller/static/js/core/tree-nav.js
Normal 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'}">▶</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));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Редактировать источник значений",
|
||||
|
||||
@@ -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": "编辑值源",
|
||||
|
||||
@@ -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,16 +110,22 @@
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
|
||||
<div id="targets-panel-content">
|
||||
<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="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>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user