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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user