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:
2026-02-18 18:11:40 +03:00
parent ed220a97e7
commit 66d1a77981
3 changed files with 65 additions and 17 deletions

View File

@@ -37,6 +37,7 @@ import {
import {
loadDashboard, startDashboardWS, stopDashboardWS,
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
toggleDashboardSection,
} from './features/dashboard.js';
import {
loadPictureSources, switchStreamTab,
@@ -151,6 +152,7 @@ Object.assign(window, {
dashboardStartTarget,
dashboardStopTarget,
dashboardStopAll,
toggleDashboardSection,
// streams / capture templates / PP templates
loadPictureSources,

View File

@@ -8,6 +8,48 @@ import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.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) {
if (!seconds || seconds <= 0) return '-';
const h = Math.floor(seconds / 3600);
@@ -67,35 +109,30 @@ export async function loadDashboard() {
if (profiles.length > 0) {
const activeProfiles = 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">
<div class="dashboard-section-header">
${t('dashboard.section.profiles')}
<span class="dashboard-section-count">${profiles.length}</span>
</div>
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
${_sectionContent('profiles', profileItems)}
</div>`;
}
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">
<div class="dashboard-section-header">
${t('dashboard.section.running')}
<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('')}
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
${_sectionContent('running', runningItems)}
</div>`;
}
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.stopped')}
<span class="dashboard-section-count">${stopped.length}</span>
</div>
${stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('')}
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}

View File

@@ -3256,6 +3256,15 @@ input:-webkit-autofill:focus {
gap: 6px;
text-transform: uppercase;
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 {