feat(redesign): collapsible dashboard sections + glass mobile-more sheet

Generalize dashboard panel expansion: Stream / On Watch / Pulse / Wires
each get a chevron toggle persisted in localStorage under
dashboard_section_state (migrates legacy dashboard_chart_visible). Drop
the redundant inner border on EventChart so it doesn't double-frame the
panel. Mobile "more" sheet becomes full-height with translucent glass
(rgba(...,0.72) + 28px blur) instead of nearly-solid.
This commit is contained in:
2026-04-25 12:37:56 +03:00
parent d356e5a3ac
commit 9eb76c1407
3 changed files with 86 additions and 28 deletions
+6 -10
View File
@@ -143,16 +143,12 @@
<style>
.chart-wrapper {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
transition: border-color 0.2s;
}
.chart-wrapper:hover {
border-color: var(--color-primary);
box-shadow: 0 0 16px var(--color-glow);
/* Outer chrome lives on the parent panel — keep this transparent so
we don't get a double border / nested card look. */
background: transparent;
border: 0;
padding: 0;
margin-bottom: 0;
}
.chart-header {
display: flex;
+17 -1
View File
@@ -547,7 +547,7 @@
{#if mobileMoreOpen}
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
<div class="mobile-more-panel"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
@@ -1066,6 +1066,22 @@
border-right: 1px solid var(--color-border);
}
.mobile-nav { display: flex !important; }
.mobile-more-panel {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
z-index: 50;
background: var(--mobile-more-bg, rgba(19, 21, 32, 0.72));
backdrop-filter: blur(28px) saturate(170%);
-webkit-backdrop-filter: blur(28px) saturate(170%);
border-top: 1px solid var(--color-rule-strong);
padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 1rem calc(1rem + env(safe-area-inset-left, 0px));
overflow-y: auto;
overscroll-behavior: contain;
}
:global([data-theme="light"]) .mobile-more-panel { --mobile-more-bg: rgba(250, 250, 254, 0.72); }
.mobile-more-panel a:hover,
.mobile-more-panel button:hover {
background: var(--color-glass-strong);
+63 -17
View File
@@ -21,11 +21,26 @@
import type { DashboardStatus } from '$lib/types';
const CHART_KEY = 'dashboard_chart_visible';
let chartVisible = $state(typeof localStorage !== 'undefined' ? localStorage.getItem(CHART_KEY) !== 'false' : true);
function toggleChart() {
chartVisible = !chartVisible;
if (typeof localStorage !== 'undefined') localStorage.setItem(CHART_KEY, String(chartVisible));
const SECTIONS_KEY = 'dashboard_section_state';
type SectionKey = 'stream' | 'on_watch' | 'pulse' | 'wires';
function loadSectionState(): Record<SectionKey, boolean> {
const defaults: Record<SectionKey, boolean> = { stream: true, on_watch: true, pulse: true, wires: true };
if (typeof localStorage === 'undefined') return defaults;
try {
const raw = localStorage.getItem(SECTIONS_KEY);
if (raw) return { ...defaults, ...JSON.parse(raw) };
// Migrate legacy single-key for the chart
const legacy = localStorage.getItem('dashboard_chart_visible');
if (legacy !== null) defaults.pulse = legacy !== 'false';
} catch { /* ignore */ }
return defaults;
}
let sectionExpanded = $state<Record<SectionKey, boolean>>(loadSectionState());
function toggleSection(key: SectionKey) {
sectionExpanded = { ...sectionExpanded, [key]: !sectionExpanded[key] };
if (typeof localStorage !== 'undefined') {
localStorage.setItem(SECTIONS_KEY, JSON.stringify(sectionExpanded));
}
}
let status = $state<DashboardStatus | null>(null);
@@ -463,16 +478,25 @@
<b>{status.total_events}</b> {t('dashboard.eventsLabel')}
</p>
</div>
{#if status.total_events > 0}
<button type="button" onclick={() => confirmClearEvents = true}
class="clear-events-btn flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg transition-colors"
title={t('dashboard.clearEvents')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
<div class="panel-head-actions">
{#if status.total_events > 0}
<button type="button" onclick={() => confirmClearEvents = true}
class="clear-events-btn flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg transition-colors"
title={t('dashboard.clearEvents')}>
<MdiIcon name="mdiTrashCanOutline" size={14} />
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
</button>
{/if}
<button type="button" onclick={() => toggleSection('stream')}
class="ghost-icon-btn"
title={sectionExpanded.stream ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.stream ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
{/if}
</div>
</header>
{#if sectionExpanded.stream}
<div in:slide={{ duration: 200 }}>
<div class="panel-filters">
<div class="flex-1 min-w-[150px] max-w-[280px]">
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
@@ -565,6 +589,8 @@
{@render paginator()}
</div>
{/if}
</div>
{/if}
</section>
<!-- On watch — provider deck. Hidden when a global provider filter is
@@ -576,8 +602,15 @@
<h2 class="panel-title">{t('dashboard.onWatchTitle')} <em>{t('dashboard.onWatchEmphasis')}</em></h2>
<p class="panel-meta"><b>{providerDeck.length}</b> {t('dashboard.providersShort')}</p>
</div>
<button type="button" onclick={() => toggleSection('on_watch')}
class="ghost-icon-btn"
title={sectionExpanded.on_watch ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.on_watch ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</header>
{#if sectionExpanded.on_watch}
<div in:slide={{ duration: 200 }}>
{#if providerDeck.length === 0}
<div class="empty-state">
<MdiIcon name="mdiServerNetwork" size={32} />
@@ -617,6 +650,8 @@
</a>
</div>
{/if}
</div>
{/if}
</section>
{/if}
</div>
@@ -628,13 +663,13 @@
<h2 class="panel-title">{t('dashboard.pulseTitle')} <em>{t('dashboard.pulseEmphasis')}</em></h2>
<p class="panel-meta">{t('dashboard.pulseSub')}</p>
</div>
<button type="button" onclick={toggleChart}
<button type="button" onclick={() => toggleSection('pulse')}
class="ghost-icon-btn"
title={chartVisible ? t('common.hide') : t('common.show')}>
<NavIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
title={sectionExpanded.pulse ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.pulse ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</header>
{#if chartVisible}
{#if sectionExpanded.pulse}
<div class="panel-body" in:slide={{ duration: 200 }}>
<EventChart days={chartDays} />
</div>
@@ -649,8 +684,14 @@
<h2 class="panel-title">{t('dashboard.wiresTitle')} <em>{t('dashboard.wiresEmphasis')}</em></h2>
<p class="panel-meta"><b>{activeWires.length}</b> {t('dashboard.wiresSub')}</p>
</div>
<button type="button" onclick={() => toggleSection('wires')}
class="ghost-icon-btn"
title={sectionExpanded.wires ? t('common.hide') : t('common.show')}>
<NavIcon name={sectionExpanded.wires ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</header>
<div class="wires-body">
{#if sectionExpanded.wires}
<div class="wires-body" in:slide={{ duration: 200 }}>
{#each activeWires as wire}
<div class="wire">
<div class="wire-from">
@@ -673,6 +714,7 @@
</div>
{/each}
</div>
{/if}
</section>
{/if}
@@ -936,6 +978,10 @@
padding: 1.25rem 1.5rem 0.85rem;
gap: 1rem;
}
.panel-head-actions {
display: flex; align-items: center; gap: 0.5rem;
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-display);
font-weight: 400;