Add collapsible dashboard sections with localStorage persistence
Each dashboard section (Profiles, Running, Stopped) now has a chevron toggle that collapses/expands the section content. Collapsed state is persisted in localStorage so it survives page reloads and WebSocket re-renders. Stop All button uses event.stopPropagation() to avoid triggering the section toggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
loadDashboard, startDashboardWS, stopDashboardWS,
|
loadDashboard, startDashboardWS, stopDashboardWS,
|
||||||
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
|
||||||
|
toggleDashboardSection,
|
||||||
} from './features/dashboard.js';
|
} from './features/dashboard.js';
|
||||||
import {
|
import {
|
||||||
loadPictureSources, switchStreamTab,
|
loadPictureSources, switchStreamTab,
|
||||||
@@ -151,6 +152,7 @@ Object.assign(window, {
|
|||||||
dashboardStartTarget,
|
dashboardStartTarget,
|
||||||
dashboardStopTarget,
|
dashboardStopTarget,
|
||||||
dashboardStopAll,
|
dashboardStopAll,
|
||||||
|
toggleDashboardSection,
|
||||||
|
|
||||||
// streams / capture templates / PP templates
|
// streams / capture templates / PP templates
|
||||||
loadPictureSources,
|
loadPictureSources,
|
||||||
|
|||||||
@@ -8,6 +8,48 @@ import { t } from '../core/i18n.js';
|
|||||||
import { escapeHtml, handle401Error } from '../core/api.js';
|
import { escapeHtml, handle401Error } from '../core/api.js';
|
||||||
import { showToast } from '../core/ui.js';
|
import { showToast } from '../core/ui.js';
|
||||||
|
|
||||||
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
|
|
||||||
|
function _getCollapsedSections() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDashboardSection(sectionKey) {
|
||||||
|
const collapsed = _getCollapsedSections();
|
||||||
|
collapsed[sectionKey] = !collapsed[sectionKey];
|
||||||
|
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
|
||||||
|
const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`);
|
||||||
|
if (!header) return;
|
||||||
|
const content = header.nextElementSibling;
|
||||||
|
const chevron = header.querySelector('.dashboard-section-chevron');
|
||||||
|
if (collapsed[sectionKey]) {
|
||||||
|
content.style.display = 'none';
|
||||||
|
chevron.textContent = '\u25B6';
|
||||||
|
} else {
|
||||||
|
content.style.display = '';
|
||||||
|
chevron.textContent = '\u25BC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sectionHeader(sectionKey, label, count, extraHtml = '') {
|
||||||
|
const collapsed = _getCollapsedSections();
|
||||||
|
const isCollapsed = !!collapsed[sectionKey];
|
||||||
|
const chevron = isCollapsed ? '\u25B6' : '\u25BC';
|
||||||
|
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
|
||||||
|
<span class="dashboard-section-chevron">${chevron}</span>
|
||||||
|
${label}
|
||||||
|
<span class="dashboard-section-count">${count}</span>
|
||||||
|
${extraHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sectionContent(sectionKey, itemsHtml) {
|
||||||
|
const collapsed = _getCollapsedSections();
|
||||||
|
const isCollapsed = !!collapsed[sectionKey];
|
||||||
|
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
function formatUptime(seconds) {
|
||||||
if (!seconds || seconds <= 0) return '-';
|
if (!seconds || seconds <= 0) return '-';
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -67,35 +109,30 @@ export async function loadDashboard() {
|
|||||||
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);
|
||||||
|
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join('');
|
||||||
|
|
||||||
html += `<div class="dashboard-section">
|
html += `<div class="dashboard-section">
|
||||||
<div class="dashboard-section-header">
|
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
|
||||||
${t('dashboard.section.profiles')}
|
${_sectionContent('profiles', profileItems)}
|
||||||
<span class="dashboard-section-count">${profiles.length}</span>
|
|
||||||
</div>
|
|
||||||
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
|
|
||||||
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (running.length > 0) {
|
if (running.length > 0) {
|
||||||
|
const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`;
|
||||||
|
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join('');
|
||||||
|
|
||||||
html += `<div class="dashboard-section">
|
html += `<div class="dashboard-section">
|
||||||
<div class="dashboard-section-header">
|
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
|
||||||
${t('dashboard.section.running')}
|
${_sectionContent('running', runningItems)}
|
||||||
<span class="dashboard-section-count">${running.length}</span>
|
|
||||||
<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>
|
|
||||||
</div>
|
|
||||||
${running.map(target => renderDashboardTarget(target, true, devicesMap)).join('')}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stopped.length > 0) {
|
if (stopped.length > 0) {
|
||||||
|
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
|
||||||
|
|
||||||
html += `<div class="dashboard-section">
|
html += `<div class="dashboard-section">
|
||||||
<div class="dashboard-section-header">
|
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
|
||||||
${t('dashboard.section.stopped')}
|
${_sectionContent('stopped', stoppedItems)}
|
||||||
<span class="dashboard-section-count">${stopped.length}</span>
|
|
||||||
</div>
|
|
||||||
${stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('')}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3256,6 +3256,15 @@ input:-webkit-autofill:focus {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section-chevron {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 10px;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section-count {
|
.dashboard-section-count {
|
||||||
|
|||||||
Reference in New Issue
Block a user