feat: collapsible chart, paginator controls, localized template slots

- Dashboard chart collapsible with state persisted in localStorage
- Events per page user-controlled (5/10/20/50) via select, persisted
- Paginator rendered both above and below event list (shared snippet)
- Removed viewport-based page size calculation
- Template slot descriptions localized (templateSlot.* i18n keys)
- Preview As target selector expanded: email, discord, slack added
- Tighter event item spacing
This commit is contained in:
2026-03-24 23:36:41 +03:00
parent 21d8ef712a
commit 337276113d
5 changed files with 188 additions and 61 deletions
+74 -56
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { providersCache } from '$lib/stores/caches.svelte';
@@ -15,6 +16,14 @@
import { getDescriptor } from '$lib/providers';
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));
}
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
@@ -36,22 +45,21 @@
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
let filterSearch = $state('');
let filterSort = $state('newest');
let eventsLimit = $state(calcPageSize());
const EVENTS_PER_PAGE_KEY = 'dashboard_events_per_page';
function loadEventsPerPage(): number {
if (typeof localStorage === 'undefined') return 10;
const stored = localStorage.getItem(EVENTS_PER_PAGE_KEY);
return stored ? parseInt(stored, 10) || 10 : 10;
}
let eventsLimit = $state(loadEventsPerPage());
let eventsOffset = $state(0);
let eventsLoading = $state(false);
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
/** Calculate how many event rows fit in the remaining viewport space. */
function calcPageSize(): number {
if (typeof window === 'undefined') return 8;
const EVENT_ROW_HEIGHT = 50;
const FIXED_OVERHEAD = 700; // slightly more for chart in Events section
const available = window.innerHeight - FIXED_OVERHEAD;
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
}
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
if (to === 0) { setter(0); return; }
const start = performance.now();
@@ -126,27 +134,10 @@
searchTimeout = setTimeout(applyFilters, 300);
}
let resizeTimeout: ReturnType<typeof setTimeout>;
function onResize() {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newLimit = calcPageSize();
if (newLimit !== eventsLimit) {
eventsLimit = newLimit;
eventsOffset = 0;
loadEvents();
}
}, 200);
}
onMount(() => {
eventsLimit = calcPageSize();
window.addEventListener('resize', onResize);
loadInitial();
return () => {
window.removeEventListener('resize', onResize);
clearTimeout(searchTimeout);
clearTimeout(resizeTimeout);
};
});
@@ -281,8 +272,56 @@
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
</div>
<!-- Chart (now inside Events section, affected by filters) -->
<EventChart days={chartDays} />
<!-- Chart -->
<button type="button" onclick={toggleChart}
class="flex items-center gap-1.5 text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors mb-2 cursor-pointer">
<MdiIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
<MdiIcon name="mdiChartBar" size={14} />
{t('dashboard.chart')}
</button>
{#if chartVisible}
<div in:slide={{ duration: 200 }}>
<EventChart days={chartDays} />
</div>
{/if}
{#snippet paginator()}
<div class="flex items-center justify-center gap-1">
{#if totalPages > 1}
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronLeft" size={16} />
</button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button onclick={() => goToPage(page)}
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
{/each}
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronRight" size={16} />
</button>
{/if}
<select value={eventsLimit}
onchange={(e) => { const v = parseInt((e.target as HTMLSelectElement).value, 10); eventsLimit = v; eventsOffset = 0; if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_PER_PAGE_KEY, String(v)); loadEvents(); }}
class="ml-2 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)] text-[var(--color-foreground)]">
{#each [5, 10, 20, 50] as n}
<option value={n}>{n}</option>
{/each}
</select>
</div>
{/snippet}
<div class="mb-3">
{@render paginator()}
</div>
{#if eventsLoading}
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
@@ -328,31 +367,10 @@
{/each}
</div>
<!-- Paginator -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-1 mt-4">
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronLeft" size={16} />
</button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
<button onclick={() => goToPage(page)}
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
{page}
</button>
{:else if page === currentPage - 2 || page === currentPage + 2}
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
{/if}
{/each}
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-30 disabled:cursor-default">
<MdiIcon name="mdiChevronRight" size={16} />
</button>
</div>
{/if}
<!-- Bottom paginator -->
<div class="mt-4">
{@render paginator()}
</div>
{/if}
{/if}
@@ -365,10 +383,10 @@
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
.event-timeline { display: flex; flex-direction: column; }
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
.event-item { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.5rem; }
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
.event-content { flex: 1; min-width: 0; padding: 0.5rem 0.875rem; border-radius: 0.625rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
</style>