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:
@@ -144,6 +144,25 @@ h2 {
|
|||||||
border-bottom-color: var(--primary-color);
|
border-bottom-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active .tab-badge {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-panel {
|
.tab-panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -200,6 +219,129 @@ h2 {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Command Palette */
|
||||||
|
#command-palette {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 15vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 60vh;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-group-header {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 16px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-result:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-result.cp-active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-result.cp-active .cp-detail {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-detail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-loading,
|
||||||
|
.cp-empty {
|
||||||
|
padding: 24px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-footer {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
header {
|
header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -61,6 +61,27 @@
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-card-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-card-link:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardHighlight {
|
||||||
|
0%, 100% { box-shadow: none; }
|
||||||
|
25%, 75% { box-shadow: 0 0 0 3px var(--primary-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-highlight,
|
||||||
|
.template-card.card-highlight {
|
||||||
|
animation: cardHighlight 2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Key Colors target styles */
|
/* Key Colors target styles */
|
||||||
.kc-rect-list {
|
.kc-rect-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -577,6 +577,33 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cs-expand-collapse-group {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-expand-collapse {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-expand-collapse:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.stream-tab-panel {
|
.stream-tab-panel {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -603,6 +630,11 @@
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtab-section-header.cs-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Collapsible card sections (cs-*) ── */
|
/* ── Collapsible card sections (cs-*) ── */
|
||||||
|
|
||||||
.cs-header {
|
.cs-header {
|
||||||
@@ -611,6 +643,11 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-color);
|
||||||
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-chevron {
|
.cs-chevron {
|
||||||
@@ -635,22 +672,31 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-filter {
|
.cs-filter-wrap {
|
||||||
|
position: relative;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 160px;
|
width: 180px;
|
||||||
max-width: 40%;
|
max-width: 40%;
|
||||||
padding: 3px 8px !important;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-filter {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 26px 4px 10px !important;
|
||||||
font-size: 0.78rem !important;
|
font-size: 0.78rem !important;
|
||||||
border: 1px solid var(--border-color) !important;
|
border: 1px solid var(--border-color) !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 14px !important;
|
||||||
background: var(--bg-color) !important;
|
background: var(--bg-secondary) !important;
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, background 0.2s, width 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-filter:focus {
|
.cs-filter:focus {
|
||||||
border-color: var(--primary-color) !important;
|
border-color: var(--primary-color) !important;
|
||||||
|
background: var(--bg-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-filter::placeholder {
|
.cs-filter::placeholder {
|
||||||
@@ -658,6 +704,28 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cs-filter-reset {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 5px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-filter-reset:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.templates-grid {
|
.templates-grid {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
|
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
|
||||||
renderModalFilterList, updateCaptureDuration,
|
renderModalFilterList, updateCaptureDuration,
|
||||||
cloneStream, cloneCaptureTemplate, clonePPTemplate,
|
cloneStream, cloneCaptureTemplate, clonePPTemplate,
|
||||||
|
expandAllStreamSections, collapseAllStreamSections,
|
||||||
} from './features/streams.js';
|
} from './features/streams.js';
|
||||||
import {
|
import {
|
||||||
createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh,
|
createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh,
|
||||||
@@ -88,6 +89,7 @@ import {
|
|||||||
startTargetProcessing, stopTargetProcessing,
|
startTargetProcessing, stopTargetProcessing,
|
||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
cloneTarget, toggleLedPreview,
|
cloneTarget, toggleLedPreview,
|
||||||
|
expandAllTargetSections, collapseAllTargetSections,
|
||||||
} from './features/targets.js';
|
} from './features/targets.js';
|
||||||
|
|
||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
@@ -123,8 +125,10 @@ import {
|
|||||||
showCSSCalibration, toggleCalibrationOverlay,
|
showCSSCalibration, toggleCalibrationOverlay,
|
||||||
} from './features/calibration.js';
|
} from './features/calibration.js';
|
||||||
|
|
||||||
// Layer 6: tabs
|
// Layer 6: tabs, navigation, command palette
|
||||||
import { switchTab, initTabs, startAutoRefresh } from './features/tabs.js';
|
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.js';
|
||||||
|
import { navigateToCard } from './core/navigation.js';
|
||||||
|
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.js';
|
||||||
|
|
||||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ Object.assign(window, {
|
|||||||
// streams / capture templates / PP templates
|
// streams / capture templates / PP templates
|
||||||
loadPictureSources,
|
loadPictureSources,
|
||||||
switchStreamTab,
|
switchStreamTab,
|
||||||
|
expandAllStreamSections, collapseAllStreamSections,
|
||||||
showAddTemplateModal,
|
showAddTemplateModal,
|
||||||
editTemplate,
|
editTemplate,
|
||||||
closeTemplateModal,
|
closeTemplateModal,
|
||||||
@@ -285,6 +290,7 @@ Object.assign(window, {
|
|||||||
loadTargetsTab,
|
loadTargetsTab,
|
||||||
loadTargets,
|
loadTargets,
|
||||||
switchTargetSubTab,
|
switchTargetSubTab,
|
||||||
|
expandAllTargetSections, collapseAllTargetSections,
|
||||||
showTargetEditor,
|
showTargetEditor,
|
||||||
closeTargetEditorModal,
|
closeTargetEditorModal,
|
||||||
forceCloseTargetEditorModal,
|
forceCloseTargetEditorModal,
|
||||||
@@ -348,14 +354,38 @@ Object.assign(window, {
|
|||||||
showCSSCalibration,
|
showCSSCalibration,
|
||||||
toggleCalibrationOverlay,
|
toggleCalibrationOverlay,
|
||||||
|
|
||||||
// tabs
|
// tabs / navigation / command palette
|
||||||
switchTab,
|
switchTab,
|
||||||
startAutoRefresh,
|
startAutoRefresh,
|
||||||
|
navigateToCard,
|
||||||
|
openCommandPalette,
|
||||||
|
closeCommandPalette,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Global Escape key handler ───
|
// ─── Global keyboard shortcuts ───
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
|
|
||||||
|
// Command palette: Ctrl+K / Cmd+K (works even in inputs)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k' && !e.altKey && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
openCommandPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
|
||||||
|
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
|
||||||
|
const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams' };
|
||||||
|
const tab = tabMap[e.key];
|
||||||
|
if (tab) {
|
||||||
|
e.preventDefault();
|
||||||
|
switchTab(tab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Close in order: overlay lightboxes first, then modals via stack
|
// Close in order: overlay lightboxes first, then modals via stack
|
||||||
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
if (document.getElementById('display-picker-lightbox').classList.contains('active')) {
|
||||||
@@ -368,6 +398,10 @@ document.addEventListener('keydown', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Browser back/forward via hash routing ───
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
|
||||||
// ─── Cleanup on page unload ───
|
// ─── Cleanup on page unload ───
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
@@ -393,6 +427,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Show content now that translations are loaded and tabs are set
|
// Show content now that translations are loaded and tabs are set
|
||||||
document.body.style.visibility = 'visible';
|
document.body.style.visibility = 'visible';
|
||||||
|
|
||||||
|
// Initialize command palette
|
||||||
|
initCommandPalette();
|
||||||
|
|
||||||
// Setup form handler
|
// Setup form handler
|
||||||
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ export class CardSection {
|
|||||||
<span class="cs-chevron">${chevron}</span>
|
<span class="cs-chevron">${chevron}</span>
|
||||||
<span class="cs-title">${t(this.titleKey)}</span>
|
<span class="cs-title">${t(this.titleKey)}</span>
|
||||||
<span class="cs-count">${count}</span>
|
<span class="cs-count">${count}</span>
|
||||||
|
<div class="cs-filter-wrap">
|
||||||
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
|
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
|
||||||
placeholder="${t('section.filter.placeholder')}" autocomplete="off">
|
placeholder="${t('section.filter.placeholder')}" autocomplete="off">
|
||||||
|
<button type="button" class="cs-filter-reset" data-cs-filter-reset="${this.sectionKey}" title="${t('section.filter.reset')}">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
|
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
|
||||||
${cardsHtml}
|
${cardsHtml}
|
||||||
@@ -96,15 +99,23 @@ export class CardSection {
|
|||||||
if (!header || !content) return;
|
if (!header || !content) return;
|
||||||
|
|
||||||
header.addEventListener('mousedown', (e) => {
|
header.addEventListener('mousedown', (e) => {
|
||||||
if (e.target.closest('.cs-filter')) return;
|
if (e.target.closest('.cs-filter-wrap')) return;
|
||||||
this._toggleCollapse(header, content);
|
this._toggleCollapse(header, content);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filterInput) {
|
if (filterInput) {
|
||||||
|
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`);
|
||||||
|
const updateResetVisibility = () => {
|
||||||
|
if (resetBtn) resetBtn.style.display = filterInput.value ? '' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
|
filterInput.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
|
if (resetBtn) resetBtn.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
|
|
||||||
let timer = null;
|
let timer = null;
|
||||||
filterInput.addEventListener('input', () => {
|
filterInput.addEventListener('input', () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
updateResetVisibility();
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
this._filterValue = filterInput.value.trim();
|
this._filterValue = filterInput.value.trim();
|
||||||
this._applyFilter(content, this._filterValue);
|
this._applyFilter(content, this._filterValue);
|
||||||
@@ -117,15 +128,28 @@ export class CardSection {
|
|||||||
filterInput.value = '';
|
filterInput.value = '';
|
||||||
this._filterValue = '';
|
this._filterValue = '';
|
||||||
this._applyFilter(content, '');
|
this._applyFilter(content, '');
|
||||||
|
updateResetVisibility();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
filterInput.value = '';
|
||||||
|
this._filterValue = '';
|
||||||
|
this._applyFilter(content, '');
|
||||||
|
updateResetVisibility();
|
||||||
|
filterInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Restore filter from before re-render
|
// Restore filter from before re-render
|
||||||
if (this._filterValue) {
|
if (this._filterValue) {
|
||||||
filterInput.value = this._filterValue;
|
filterInput.value = this._filterValue;
|
||||||
this._applyFilter(content, this._filterValue);
|
this._applyFilter(content, this._filterValue);
|
||||||
}
|
}
|
||||||
|
updateResetVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag card elements with their source HTML for future reconciliation
|
// Tag card elements with their source HTML for future reconciliation
|
||||||
@@ -205,6 +229,53 @@ export class CardSection {
|
|||||||
for (const s of sections) s.bind();
|
for (const s of sections) s.bind();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Expand all given sections. */
|
||||||
|
static expandAll(sections) {
|
||||||
|
const map = _getCollapsedMap();
|
||||||
|
for (const s of sections) {
|
||||||
|
map[s.sectionKey] = false;
|
||||||
|
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
||||||
|
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
||||||
|
if (content) content.style.display = '';
|
||||||
|
if (header) {
|
||||||
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
|
if (chevron) chevron.textContent = '\u25BC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse all given sections. */
|
||||||
|
static collapseAll(sections) {
|
||||||
|
const map = _getCollapsedMap();
|
||||||
|
for (const s of sections) {
|
||||||
|
map[s.sectionKey] = true;
|
||||||
|
const content = document.querySelector(`[data-cs-content="${s.sectionKey}"]`);
|
||||||
|
const header = document.querySelector(`[data-cs-toggle="${s.sectionKey}"]`);
|
||||||
|
if (content) content.style.display = 'none';
|
||||||
|
if (header) {
|
||||||
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
|
if (chevron) chevron.textContent = '\u25B6';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Programmatically expand this section if collapsed. */
|
||||||
|
expand() {
|
||||||
|
const header = document.querySelector(`[data-cs-toggle="${this.sectionKey}"]`);
|
||||||
|
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||||
|
if (!header || !content) return;
|
||||||
|
const map = _getCollapsedMap();
|
||||||
|
if (map[this.sectionKey]) {
|
||||||
|
map[this.sectionKey] = false;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||||
|
content.style.display = '';
|
||||||
|
const chevron = header.querySelector('.cs-chevron');
|
||||||
|
if (chevron) chevron.textContent = '\u25BC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── private ──
|
// ── private ──
|
||||||
|
|
||||||
_tagCards(content) {
|
_tagCards(content) {
|
||||||
|
|||||||
294
server/src/wled_controller/static/js/core/command-palette.js
Normal file
294
server/src/wled_controller/static/js/core/command-palette.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from './api.js';
|
||||||
|
import { t } from './i18n.js';
|
||||||
|
import { navigateToCard } from './navigation.js';
|
||||||
|
|
||||||
|
let _isOpen = false;
|
||||||
|
let _items = [];
|
||||||
|
let _filtered = [];
|
||||||
|
let _selectedIdx = 0;
|
||||||
|
let _loading = false;
|
||||||
|
|
||||||
|
// ─── Entity definitions: endpoint → palette items ───
|
||||||
|
|
||||||
|
const _streamSubTab = {
|
||||||
|
raw: { sub: 'raw', section: 'raw-streams' },
|
||||||
|
processed: { sub: 'processed', section: 'proc-streams' },
|
||||||
|
static_image: { sub: 'static_image', section: 'static-streams' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _mapEntities(data, mapFn) {
|
||||||
|
if (Array.isArray(data)) return data.map(mapFn).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildItems(results) {
|
||||||
|
const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams] = results;
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
_mapEntities(devices, d => items.push({
|
||||||
|
name: d.name, detail: d.device_type, group: 'devices', icon: '🖥️',
|
||||||
|
nav: ['targets', 'led', 'led-devices', 'data-device-id', d.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(targets, tgt => {
|
||||||
|
if (tgt.target_type === 'key_colors') {
|
||||||
|
items.push({
|
||||||
|
name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: '🎨',
|
||||||
|
nav: ['targets', 'key_colors', 'kc-targets', 'data-kc-target-id', tgt.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: '⚡',
|
||||||
|
nav: ['targets', 'led', 'led-targets', 'data-target-id', tgt.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_mapEntities(css, c => items.push({
|
||||||
|
name: c.name, detail: c.css_type || c.source_type, group: 'css', icon: '🎨',
|
||||||
|
nav: ['targets', 'led', 'led-css', 'data-css-id', c.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(profiles, p => items.push({
|
||||||
|
name: p.name, detail: p.enabled ? 'enabled' : '', group: 'profiles', icon: '📋',
|
||||||
|
nav: ['profiles', null, 'profiles', 'data-profile-id', p.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(capTempl, ct => items.push({
|
||||||
|
name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: '📷',
|
||||||
|
nav: ['streams', 'raw', 'raw-templates', 'data-template-id', ct.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(ppTempl, pp => items.push({
|
||||||
|
name: pp.name, detail: '', group: 'pp_templates', icon: '🔧',
|
||||||
|
nav: ['streams', 'processed', 'proc-templates', 'data-pp-template-id', pp.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(patTempl, pt => items.push({
|
||||||
|
name: pt.name, detail: '', group: 'pattern_templates', icon: '🧩',
|
||||||
|
nav: ['targets', 'key_colors', 'kc-patterns', 'data-pattern-template-id', pt.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(audioSrc, a => {
|
||||||
|
const section = a.source_type === 'mono' ? 'audio-mono' : 'audio-multi';
|
||||||
|
items.push({
|
||||||
|
name: a.name, detail: a.source_type, group: 'audio', icon: '🎵',
|
||||||
|
nav: ['streams', 'audio', section, 'data-id', a.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_mapEntities(valSrc, v => items.push({
|
||||||
|
name: v.name, detail: v.source_type, group: 'value', icon: '🔢',
|
||||||
|
nav: ['streams', 'value', 'value-sources', 'data-id', v.id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
_mapEntities(streams, s => {
|
||||||
|
const mapping = _streamSubTab[s.stream_type] || _streamSubTab.raw;
|
||||||
|
const icon = s.stream_type === 'processed' ? '🎞️' : s.stream_type === 'static_image' ? '🖼️' : '🖥️';
|
||||||
|
items.push({
|
||||||
|
name: s.name, detail: s.stream_type, group: 'streams', icon,
|
||||||
|
nav: ['streams', mapping.sub, mapping.section, 'data-stream-id', s.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps endpoint → response key that holds the array
|
||||||
|
const _responseKeys = [
|
||||||
|
['/devices', 'devices'],
|
||||||
|
['/picture-targets', 'targets'],
|
||||||
|
['/color-strip-sources', 'sources'],
|
||||||
|
['/profiles', 'profiles'],
|
||||||
|
['/capture-templates', 'templates'],
|
||||||
|
['/postprocessing-templates','templates'],
|
||||||
|
['/pattern-templates', 'templates'],
|
||||||
|
['/audio-sources', 'sources'],
|
||||||
|
['/value-sources', 'sources'],
|
||||||
|
['/picture-sources', 'streams'],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function _fetchAllEntities() {
|
||||||
|
const results = await Promise.all(
|
||||||
|
_responseKeys.map(([ep, key]) =>
|
||||||
|
fetchWithAuth(ep, { retry: false, timeout: 5000 })
|
||||||
|
.then(r => r.ok ? r.json() : {})
|
||||||
|
.then(data => data[key] || [])
|
||||||
|
.catch(() => []))
|
||||||
|
);
|
||||||
|
return _buildItems(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filtering ───
|
||||||
|
|
||||||
|
function _filterItems(query) {
|
||||||
|
if (!query) return _items;
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
const terms = lower.split(/\s+/).filter(Boolean);
|
||||||
|
return _items.filter(item => {
|
||||||
|
const text = `${item.name} ${item.detail} ${t('search.group.' + item.group)}`.toLowerCase();
|
||||||
|
return terms.every(term => text.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group ordering ───
|
||||||
|
|
||||||
|
const _groupOrder = [
|
||||||
|
'devices', 'targets', 'kc_targets', 'css', 'profiles',
|
||||||
|
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||||
|
'audio', 'value',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Rendering ───
|
||||||
|
|
||||||
|
function _render() {
|
||||||
|
const results = document.getElementById('cp-results');
|
||||||
|
if (!results) return;
|
||||||
|
|
||||||
|
if (_loading) {
|
||||||
|
results.innerHTML = `<div class="cp-loading">${t('search.loading')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_filtered.length === 0) {
|
||||||
|
results.innerHTML = `<div class="cp-empty">${t('search.no_results')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items preserving order
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const g of _groupOrder) grouped.set(g, []);
|
||||||
|
for (const item of _filtered) {
|
||||||
|
if (!grouped.has(item.group)) grouped.set(item.group, []);
|
||||||
|
grouped.get(item.group).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let idx = 0;
|
||||||
|
for (const [group, items] of grouped) {
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`;
|
||||||
|
for (const item of items) {
|
||||||
|
const active = idx === _selectedIdx ? ' cp-active' : '';
|
||||||
|
html += `<div class="cp-result${active}" data-cp-idx="${idx}">` +
|
||||||
|
`<span class="cp-icon">${item.icon}</span>` +
|
||||||
|
`<span class="cp-name">${_escHtml(item.name)}</span>` +
|
||||||
|
(item.detail ? `<span class="cp-detail">${_escHtml(item.detail)}</span>` : '') +
|
||||||
|
`</div>`;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.innerHTML = html;
|
||||||
|
_scrollActive(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scrollActive(container) {
|
||||||
|
const active = container.querySelector('.cp-active');
|
||||||
|
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = text;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Open / Close ───
|
||||||
|
|
||||||
|
export async function openCommandPalette() {
|
||||||
|
if (_isOpen) return;
|
||||||
|
_isOpen = true;
|
||||||
|
_selectedIdx = 0;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('command-palette');
|
||||||
|
const input = document.getElementById('cp-input');
|
||||||
|
overlay.style.display = '';
|
||||||
|
input.value = '';
|
||||||
|
input.placeholder = t('search.placeholder');
|
||||||
|
_loading = true;
|
||||||
|
_render();
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_items = await _fetchAllEntities();
|
||||||
|
} catch {
|
||||||
|
_items = [];
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
_filtered = _filterItems(input.value.trim());
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeCommandPalette() {
|
||||||
|
if (!_isOpen) return;
|
||||||
|
_isOpen = false;
|
||||||
|
const overlay = document.getElementById('command-palette');
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
_items = [];
|
||||||
|
_filtered = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Event handlers ───
|
||||||
|
|
||||||
|
function _onInput() {
|
||||||
|
const input = document.getElementById('cp-input');
|
||||||
|
_filtered = _filterItems(input.value.trim());
|
||||||
|
_selectedIdx = 0;
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onKeydown(e) {
|
||||||
|
if (!_isOpen) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
_selectedIdx = Math.min(_selectedIdx + 1, _filtered.length - 1);
|
||||||
|
_render();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
_selectedIdx = Math.max(_selectedIdx - 1, 0);
|
||||||
|
_render();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
_selectCurrent();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onClick(e) {
|
||||||
|
const row = e.target.closest('.cp-result');
|
||||||
|
if (row) {
|
||||||
|
_selectedIdx = parseInt(row.dataset.cpIdx, 10);
|
||||||
|
_selectCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.classList.contains('cp-backdrop')) {
|
||||||
|
closeCommandPalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _selectCurrent() {
|
||||||
|
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
|
||||||
|
const item = _filtered[_selectedIdx];
|
||||||
|
closeCommandPalette();
|
||||||
|
navigateToCard(...item.nav);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initialization ───
|
||||||
|
|
||||||
|
export function initCommandPalette() {
|
||||||
|
const overlay = document.getElementById('command-palette');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const input = document.getElementById('cp-input');
|
||||||
|
input.addEventListener('input', _onInput);
|
||||||
|
input.addEventListener('keydown', _onKeydown);
|
||||||
|
overlay.addEventListener('click', _onClick);
|
||||||
|
}
|
||||||
66
server/src/wled_controller/static/js/core/navigation.js
Normal file
66
server/src/wled_controller/static/js/core/navigation.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Cross-entity navigation — navigate to a specific card on any tab/subtab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { switchTab } from '../features/tabs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a card on any tab/subtab, expanding the section and scrolling to it.
|
||||||
|
*
|
||||||
|
* @param {string} tab Main tab: 'dashboard' | 'profiles' | 'targets' | 'streams'
|
||||||
|
* @param {string|null} subTab Sub-tab key or null
|
||||||
|
* @param {string|null} sectionKey CardSection key to expand, or null
|
||||||
|
* @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id')
|
||||||
|
* @param {string} cardValue Value of the data attribute
|
||||||
|
*/
|
||||||
|
export function navigateToCard(tab, subTab, sectionKey, cardAttr, cardValue) {
|
||||||
|
// Push current location to history so browser back returns here
|
||||||
|
history.pushState(null, '', location.hash || '#');
|
||||||
|
switchTab(tab);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (subTab) {
|
||||||
|
if (tab === 'targets' && typeof window.switchTargetSubTab === 'function') {
|
||||||
|
window.switchTargetSubTab(subTab);
|
||||||
|
} else if (tab === 'streams' && typeof window.switchStreamTab === 'function') {
|
||||||
|
window.switchStreamTab(subTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand section if collapsed
|
||||||
|
if (sectionKey) {
|
||||||
|
const content = document.querySelector(`[data-cs-content="${sectionKey}"]`);
|
||||||
|
const header = document.querySelector(`[data-cs-toggle="${sectionKey}"]`);
|
||||||
|
if (content && content.style.display === 'none') {
|
||||||
|
content.style.display = '';
|
||||||
|
const chevron = header?.querySelector('.cs-chevron');
|
||||||
|
if (chevron) chevron.textContent = '\u25BC';
|
||||||
|
const map = JSON.parse(localStorage.getItem('sections_collapsed') || '{}');
|
||||||
|
map[sectionKey] = false;
|
||||||
|
localStorage.setItem('sections_collapsed', JSON.stringify(map));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for card to appear in DOM (tab data may load async)
|
||||||
|
_waitForCard(cardAttr, cardValue, 3000).then(card => {
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('card-highlight');
|
||||||
|
setTimeout(() => card.classList.remove('card-highlight'), 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _waitForCard(cardAttr, cardValue, timeout) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const card = document.querySelector(`[${cardAttr}="${cardValue}"]`);
|
||||||
|
if (card) { resolve(card); return; }
|
||||||
|
|
||||||
|
const timer = setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const el = document.querySelector(`[${cardAttr}="${cardValue}"]`);
|
||||||
|
if (el) { observer.disconnect(); clearTimeout(timer); resolve(el); }
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'
|
|||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, formatUptime } from '../core/ui.js';
|
import { showToast, formatUptime } from '../core/ui.js';
|
||||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.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 DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
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 running = enriched.filter(t => t.state && t.state.processing);
|
||||||
const stopped = 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)
|
// Check if we can do an in-place metrics update (same targets, not first load)
|
||||||
const newRunningIds = running.map(t => t.id).sort().join(',');
|
const newRunningIds = running.map(t => t.id).sort().join(',');
|
||||||
@@ -365,6 +366,7 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
if (profiles.length > 0) {
|
if (profiles.length > 0) {
|
||||||
const activeProfiles = profiles.filter(p => p.is_active);
|
const activeProfiles = profiles.filter(p => p.is_active);
|
||||||
const inactiveProfiles = 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('');
|
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join('');
|
||||||
|
|
||||||
dynamicHtml += `<div class="dashboard-section">
|
dynamicHtml += `<div class="dashboard-section">
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</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" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</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">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||||
<span class="stream-card-prop" title="${t('kc.fps')}">⚡ ${kcSettings.fps ?? 10}</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>
|
||||||
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
||||||
<input type="range" class="brightness-slider" min="0" max="255"
|
<input type="range" class="brightness-slider" min="0" max="255"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { t } from '../core/i18n.js';
|
|||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
|
import { updateTabBadge } from './tabs.js';
|
||||||
|
|
||||||
class ProfileEditorModal extends Modal {
|
class ProfileEditorModal extends Modal {
|
||||||
constructor() { super('profile-editor-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)
|
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
|
||||||
);
|
);
|
||||||
set_profilesCache(data.profiles);
|
set_profilesCache(data.profiles);
|
||||||
|
const activeCount = data.profiles.filter(p => p.is_active).length;
|
||||||
|
updateTabBadge('profiles', activeCount);
|
||||||
renderProfiles(data.profiles, runningTargetIds);
|
renderProfiles(data.profiles, runningTargetIds);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { Modal } from '../core/modal.js';
|
|||||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
|
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner } from '../core/ui.js';
|
||||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
|
import { updateSubTabHash } from './tabs.js';
|
||||||
import { createValueSourceCard } from './value-sources.js';
|
import { createValueSourceCard } from './value-sources.js';
|
||||||
|
|
||||||
// ── Card section instances ──
|
// ── Card section instances ──
|
||||||
@@ -560,6 +561,25 @@ export function switchStreamTab(tabKey) {
|
|||||||
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
|
panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`)
|
||||||
);
|
);
|
||||||
localStorage.setItem('activeStreamTab', 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) {
|
function renderPictureSourcesList(streams) {
|
||||||
@@ -580,19 +600,21 @@ function renderPictureSourcesList(streams) {
|
|||||||
detailsHtml = `<div class="stream-card-props">
|
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.display')}">🖥️ ${stream.display_index ?? 0}</span>
|
||||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</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>`;
|
</div>`;
|
||||||
} else if (stream.stream_type === 'processed') {
|
} else if (stream.stream_type === 'processed') {
|
||||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (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 = '';
|
let ppTmplName = '';
|
||||||
if (stream.postprocessing_template_id) {
|
if (stream.postprocessing_template_id) {
|
||||||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||||||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||||||
}
|
}
|
||||||
detailsHtml = `<div class="stream-card-props">
|
detailsHtml = `<div class="stream-card-props">
|
||||||
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</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" title="${t('streams.pp_template')}">📋 ${ppTmplName}</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>`;
|
</div>`;
|
||||||
} else if (stream.stream_type === 'static_image') {
|
} else if (stream.stream_type === 'static_image') {
|
||||||
const src = stream.image_source || '';
|
const src = stream.image_source || '';
|
||||||
@@ -695,7 +717,7 @@ function renderPictureSourcesList(streams) {
|
|||||||
|
|
||||||
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
|
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>`
|
`<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 renderAudioSourceCard = (src) => {
|
||||||
const isMono = src.source_type === 'mono';
|
const isMono = src.source_type === 'mono';
|
||||||
|
|||||||
@@ -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';
|
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 => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
const isActive = btn.dataset.tab === name;
|
const isActive = btn.dataset.tab === name;
|
||||||
btn.classList.toggle('active', isActive);
|
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}`));
|
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
||||||
localStorage.setItem('activeTab', 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') {
|
if (name === 'dashboard') {
|
||||||
// Use window.* to avoid circular imports with feature modules
|
// Use window.* to avoid circular imports with feature modules
|
||||||
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
|
if (apiKey && typeof window.loadDashboard === 'function') window.loadDashboard();
|
||||||
@@ -30,13 +56,44 @@ export function switchTab(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initTabs() {
|
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'
|
// Migrate legacy 'devices' tab to 'targets'
|
||||||
if (saved === 'devices') saved = 'targets';
|
if (saved === 'devices') saved = 'targets';
|
||||||
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
|
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
|
||||||
switchTab(saved);
|
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() {
|
export function startAutoRefresh() {
|
||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(refreshInterval);
|
clearInterval(refreshInterval);
|
||||||
@@ -56,3 +113,35 @@ export function startAutoRefresh() {
|
|||||||
}
|
}
|
||||||
}, dashboardPollInterval));
|
}, 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from '.
|
|||||||
import { createColorStripCard } from './color-strips.js';
|
import { createColorStripCard } from './color-strips.js';
|
||||||
import { getValueSourceIcon } from './value-sources.js';
|
import { getValueSourceIcon } from './value-sources.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
|
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||||
|
|
||||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||||
// (pattern-templates.js calls window.loadTargetsTab)
|
// (pattern-templates.js calls window.loadTargetsTab)
|
||||||
@@ -374,6 +375,23 @@ export function switchTargetSubTab(tabKey) {
|
|||||||
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
|
||||||
);
|
);
|
||||||
localStorage.setItem('activeTargetSubTab', 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;
|
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 ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
|
||||||
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
|
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"
|
// Backward compat: map stored "wled" sub-tab to "led"
|
||||||
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
if (activeSubTab === 'wled') activeSubTab = 'led';
|
||||||
@@ -477,7 +499,7 @@ export async function loadTargetsTab() {
|
|||||||
|
|
||||||
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
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>`
|
`<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
|
// Use window.createPatternTemplateCard to avoid circular import
|
||||||
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
||||||
@@ -676,10 +698,10 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">
|
<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" 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>
|
<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" 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>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
|
|||||||
@@ -253,6 +253,9 @@
|
|||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.clone": "Clone",
|
"common.clone": "Clone",
|
||||||
"section.filter.placeholder": "Filter...",
|
"section.filter.placeholder": "Filter...",
|
||||||
|
"section.filter.reset": "Clear filter",
|
||||||
|
"section.expand_all": "Expand all sections",
|
||||||
|
"section.collapse_all": "Collapse all sections",
|
||||||
"streams.title": "\uD83D\uDCFA Sources",
|
"streams.title": "\uD83D\uDCFA Sources",
|
||||||
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
|
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
|
||||||
"streams.group.raw": "Screen Capture",
|
"streams.group.raw": "Screen Capture",
|
||||||
@@ -849,5 +852,20 @@
|
|||||||
"value_source.error.name_required": "Please enter a name",
|
"value_source.error.name_required": "Please enter a name",
|
||||||
"targets.brightness_vs": "Brightness Source:",
|
"targets.brightness_vs": "Brightness Source:",
|
||||||
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
||||||
"targets.brightness_vs.none": "None (device brightness)"
|
"targets.brightness_vs.none": "None (device brightness)",
|
||||||
|
|
||||||
|
"search.placeholder": "Search entities... (Ctrl+K)",
|
||||||
|
"search.loading": "Loading...",
|
||||||
|
"search.no_results": "No results found",
|
||||||
|
"search.group.devices": "Devices",
|
||||||
|
"search.group.targets": "LED Targets",
|
||||||
|
"search.group.kc_targets": "Key Colors Targets",
|
||||||
|
"search.group.css": "Color Strip Sources",
|
||||||
|
"search.group.profiles": "Profiles",
|
||||||
|
"search.group.streams": "Picture Streams",
|
||||||
|
"search.group.capture_templates": "Capture Templates",
|
||||||
|
"search.group.pp_templates": "Post-Processing Templates",
|
||||||
|
"search.group.pattern_templates": "Pattern Templates",
|
||||||
|
"search.group.audio": "Audio Sources",
|
||||||
|
"search.group.value": "Value Sources"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,9 @@
|
|||||||
"common.edit": "Редактировать",
|
"common.edit": "Редактировать",
|
||||||
"common.clone": "Клонировать",
|
"common.clone": "Клонировать",
|
||||||
"section.filter.placeholder": "Фильтр...",
|
"section.filter.placeholder": "Фильтр...",
|
||||||
|
"section.filter.reset": "Очистить фильтр",
|
||||||
|
"section.expand_all": "Развернуть все секции",
|
||||||
|
"section.collapse_all": "Свернуть все секции",
|
||||||
"streams.title": "\uD83D\uDCFA Источники",
|
"streams.title": "\uD83D\uDCFA Источники",
|
||||||
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
|
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
|
||||||
"streams.group.raw": "Захват Экрана",
|
"streams.group.raw": "Захват Экрана",
|
||||||
@@ -849,5 +852,20 @@
|
|||||||
"value_source.error.name_required": "Введите название",
|
"value_source.error.name_required": "Введите название",
|
||||||
"targets.brightness_vs": "Источник яркости:",
|
"targets.brightness_vs": "Источник яркости:",
|
||||||
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
||||||
"targets.brightness_vs.none": "Нет (яркость устройства)"
|
"targets.brightness_vs.none": "Нет (яркость устройства)",
|
||||||
|
|
||||||
|
"search.placeholder": "Поиск... (Ctrl+K)",
|
||||||
|
"search.loading": "Загрузка...",
|
||||||
|
"search.no_results": "Ничего не найдено",
|
||||||
|
"search.group.devices": "Устройства",
|
||||||
|
"search.group.targets": "LED-цели",
|
||||||
|
"search.group.kc_targets": "Цели Key Colors",
|
||||||
|
"search.group.css": "Источники цветных лент",
|
||||||
|
"search.group.profiles": "Профили",
|
||||||
|
"search.group.streams": "Потоки изображений",
|
||||||
|
"search.group.capture_templates": "Шаблоны захвата",
|
||||||
|
"search.group.pp_templates": "Шаблоны постобработки",
|
||||||
|
"search.group.pattern_templates": "Шаблоны паттернов",
|
||||||
|
"search.group.audio": "Аудиоисточники",
|
||||||
|
"search.group.value": "Источники значений"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,10 @@
|
|||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-bar" role="tablist">
|
<div class="tab-bar" role="tablist">
|
||||||
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
|
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
|
||||||
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles"><span data-i18n="profiles.title">📋 Profiles</span></button>
|
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><span data-i18n="profiles.title">📋 Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button>
|
||||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets"><span data-i18n="targets.title">⚡ Targets</span></button>
|
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><span data-i18n="targets.title">⚡ Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
||||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams"><span data-i18n="streams.title">📺 Sources</span></button>
|
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><span data-i18n="streams.title">📺 Sources</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||||
@@ -79,7 +79,8 @@
|
|||||||
<script>
|
<script>
|
||||||
// Apply saved tab immediately during parse to prevent visible jump
|
// Apply saved tab immediately during parse to prevent visible jump
|
||||||
(function() {
|
(function() {
|
||||||
var saved = localStorage.getItem('activeTab');
|
var hash = location.hash.replace(/^#/, '');
|
||||||
|
var saved = hash ? hash.split('/')[0] : localStorage.getItem('activeTab');
|
||||||
if (saved === 'devices') saved = 'targets';
|
if (saved === 'devices') saved = 'targets';
|
||||||
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
|
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
|
||||||
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
|
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
|
||||||
@@ -124,6 +125,15 @@
|
|||||||
{% include 'partials/image-lightbox.html' %}
|
{% include 'partials/image-lightbox.html' %}
|
||||||
{% include 'partials/display-picker.html' %}
|
{% include 'partials/display-picker.html' %}
|
||||||
|
|
||||||
|
<div id="command-palette" style="display:none">
|
||||||
|
<div class="cp-backdrop"></div>
|
||||||
|
<div class="cp-dialog">
|
||||||
|
<input id="cp-input" class="cp-input" placeholder="Search..." autocomplete="off">
|
||||||
|
<div id="cp-results" class="cp-results"></div>
|
||||||
|
<div class="cp-footer">↑↓ navigate · Enter select · Esc close</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/static/js/app.js"></script>
|
<script type="module" src="/static/js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme
|
// Initialize theme
|
||||||
|
|||||||
Reference in New Issue
Block a user