Add WebUI navigation improvements: keyboard shortcuts, hash routing, command palette, cross-entity links

- Keyboard shortcuts: Ctrl+1-4 for tab switching
- URL hash routing: #tab/subtab format with browser back/forward support
- Tab count badges: running targets and active profiles counts
- Cross-entity quick links: clickable references navigate to related cards
- Command palette (Ctrl+K): global search across all entities with keyboard navigation
- Expand/collapse all sections: buttons in sub-tab bars
- Sticky section headers: headers pin while scrolling long card grids
- Improved section filter: better styling with reset button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 02:40:24 +03:00
parent a82eec7a06
commit f67936c977
16 changed files with 917 additions and 34 deletions

View File

@@ -7,7 +7,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'
import { t } from '../core/i18n.js';
import { showToast, formatUptime } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh } from './tabs.js';
import { startAutoRefresh, updateTabBadge } from './tabs.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120;
@@ -338,6 +338,7 @@ export async function loadDashboard(forceFullRender = false) {
const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
updateTabBadge('targets', running.length);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
@@ -365,6 +366,7 @@ export async function loadDashboard(forceFullRender = false) {
if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active);
updateTabBadge('profiles', activeProfiles.length);
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join('');
dynamicHtml += `<div class="dashboard-section">

View File

@@ -78,11 +78,11 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','key_colors','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>📄 ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}">⚡ ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full" title="${t('targets.brightness_vs')}">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
<input type="range" class="brightness-slider" min="0" max="255"

View File

@@ -8,6 +8,7 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
class ProfileEditorModal extends Modal {
constructor() { super('profile-editor-modal'); }
@@ -58,6 +59,8 @@ export async function loadProfiles() {
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
);
set_profilesCache(data.profiles);
const activeCount = data.profiles.filter(p => p.is_active).length;
updateTabBadge('profiles', activeCount);
renderProfiles(data.profiles, runningTargetIds);
} catch (error) {
if (error.isAuth) return;

View File

@@ -28,6 +28,7 @@ import { Modal } from '../core/modal.js';
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash } from './tabs.js';
import { createValueSourceCard } from './value-sources.js';
// ── Card section instances ──
@@ -560,6 +561,25 @@ export function switchStreamTab(tabKey) {
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
);
localStorage.setItem('activeStreamTab', tabKey);
updateSubTabHash('streams', tabKey);
}
const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
audio: [csAudioMulti, csAudioMono],
value: [csValueSources],
};
export function expandAllStreamSections() {
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
CardSection.expandAll(_streamSectionMap[activeTab] || []);
}
export function collapseAllStreamSections() {
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
CardSection.collapseAll(_streamSectionMap[activeTab] || []);
}
function renderPictureSourcesList(streams) {
@@ -580,19 +600,21 @@ function renderPictureSourcesList(streams) {
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📋 ${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-templates','data-id','${stream.capture_template_id}')">📋 ${capTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
let ppTmplName = '';
if (stream.postprocessing_template_id) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">📋 ${ppTmplName}</span>` : ''}
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','processed','proc-templates','data-id','${stream.postprocessing_template_id}')">📋 ${ppTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
@@ -695,7 +717,7 @@ function renderPictureSourcesList(streams) {
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} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
const renderAudioSourceCard = (src) => {
const isMono = src.source_type === 'mono';

View File

@@ -1,10 +1,26 @@
/**
* Tab switching — switchTab, initTabs, startAutoRefresh.
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
*/
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js';
export function switchTab(name) {
/** Parse location.hash into {tab, subTab}. */
export function parseHash() {
const hash = location.hash.replace(/^#/, '');
if (!hash) return {};
const [tab, subTab] = hash.split('/');
return { tab, subTab };
}
/** Update the URL hash without triggering popstate. */
function _setHash(tab, subTab) {
const hash = '#' + (subTab ? `${tab}/${subTab}` : tab);
history.replaceState(null, '', hash);
}
let _suppressHashUpdate = false;
export function switchTab(name, { updateHash = true } = {}) {
document.querySelectorAll('.tab-btn').forEach(btn => {
const isActive = btn.dataset.tab === name;
btn.classList.toggle('active', isActive);
@@ -12,6 +28,16 @@ export function switchTab(name) {
});
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (updateHash && !_suppressHashUpdate) {
const subTab = name === 'targets'
? (localStorage.getItem('activeTargetSubTab') || 'led')
: name === 'streams'
? (localStorage.getItem('activeStreamTab') || 'raw')
: null;
_setHash(name, subTab);
}
if (name === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
@@ -30,13 +56,44 @@ export function switchTab(name) {
}
export function initTabs() {
let saved = localStorage.getItem('activeTab');
// Hash takes priority over localStorage
const hashRoute = parseHash();
let saved;
if (hashRoute.tab && document.getElementById(`tab-${hashRoute.tab}`)) {
saved = hashRoute.tab;
// Pre-set sub-tab so the sub-tab switch functions pick it up
if (hashRoute.subTab) {
if (saved === 'targets') localStorage.setItem('activeTargetSubTab', hashRoute.subTab);
if (saved === 'streams') localStorage.setItem('activeStreamTab', hashRoute.subTab);
}
} else {
saved = localStorage.getItem('activeTab');
}
// Migrate legacy 'devices' tab to 'targets'
if (saved === 'devices') saved = 'targets';
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
switchTab(saved);
}
/** Update hash when sub-tab changes. Called from targets.js / streams.js. */
export function updateSubTabHash(tab, subTab) {
_setHash(tab, subTab);
}
/** Update the count badge on a main tab button. Hidden when count is 0. */
export function updateTabBadge(tabName, count) {
const badge = document.getElementById(`tab-badge-${tabName}`);
if (!badge) return;
if (count > 0) {
badge.textContent = count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
export function startAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
@@ -56,3 +113,35 @@ export function startAutoRefresh() {
}
}, dashboardPollInterval));
}
/**
* Handle browser back/forward via popstate.
* Called from app.js.
*/
export function handlePopState() {
const hashRoute = parseHash();
if (!hashRoute.tab) return;
const currentTab = localStorage.getItem('activeTab');
_suppressHashUpdate = true;
if (hashRoute.tab !== currentTab) {
switchTab(hashRoute.tab, { updateHash: false });
}
if (hashRoute.subTab) {
if (hashRoute.tab === 'targets') {
const currentSub = localStorage.getItem('activeTargetSubTab');
if (hashRoute.subTab !== currentSub && typeof window.switchTargetSubTab === 'function') {
window.switchTargetSubTab(hashRoute.subTab);
}
} else if (hashRoute.tab === 'streams') {
const currentSub = localStorage.getItem('activeStreamTab');
if (hashRoute.subTab !== currentSub && typeof window.switchStreamTab === 'function') {
window.switchStreamTab(hashRoute.subTab);
}
}
}
_suppressHashUpdate = false;
}

View File

@@ -19,6 +19,7 @@ import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from '.
import { createColorStripCard } from './color-strips.js';
import { getValueSourceIcon } from './value-sources.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
@@ -374,6 +375,23 @@ export function switchTargetSubTab(tabKey) {
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
updateSubTabHash('targets', tabKey);
}
export function expandAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
const sections = activeSubTab === 'key_colors'
? [csKCTargets, csPatternTemplates]
: [csDevices, csColorStrips, csLedTargets];
CardSection.expandAll(sections);
}
export function collapseAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
const sections = activeSubTab === 'key_colors'
? [csKCTargets, csPatternTemplates]
: [csDevices, csColorStrips, csLedTargets];
CardSection.collapseAll(sections);
}
let _loadTargetsLock = false;
@@ -466,6 +484,10 @@ export async function loadTargetsTab() {
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
// Update tab badge with running target count
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
updateTabBadge('targets', runningCount);
// Backward compat: map stored "wled" sub-tab to "led"
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
if (activeSubTab === 'wled') activeSubTab = 'led';
@@ -477,7 +499,7 @@ export async function loadTargetsTab() {
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} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
@@ -676,10 +698,10 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30}</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full" title="${t('targets.brightness_vs')}">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>🎞️ ${cssSummary}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
</div>
<div class="card-content">
${isProcessing ? `