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._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.icon ? `${group.icon}` : ''} + ${t(group.titleKey)} + ${groupCount} +
+
+ ${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 @@
-
-
+
+ +
+
+
-
-
+
+ +
+
+