Files
notify-bridge/frontend/src/routes/+page.svelte
T
alexei.dolgolyov 1895c5e2d4 fix(redesign): brand snap, event sentences, palette glass, full width
Round-up of feedback:

- Brand: drop italic-gradient 'Bridge' wordmark + 'SERVICE
  NOTIFICATIONS' tag. Now plain 'Notify Bridge' bold sans + 'v0.5.2'
  mono — exactly what the Aurora mockup shows.
- Event rows: replaced [collection_name] [event_tag] [count_tag] with
  a sentence form — bold provider, muted verb, bold count, gradient
  italic-feeling collection. Matches the mockup's 'Immich added 14
  assets to «...»' pattern. Tracker → provider trail kept underneath.
- SearchPalette: re-skinned to Aurora glass — solid surface (was
  using the now-translucent --color-card token and was nearly
  invisible), backdrop blur, hairline section headers with
  letterspaced mono labels, gradient active row, glass-bordered
  result icons + detail pills.
- 'On watch' provider deck hidden when a global provider filter is
  active (deck would only show that one provider — redundant). Two-col
  grid collapses to single full-width column in that mode.
- Layout: dropped the max-w-7xl cap on the topbar and the page
  container. Pages now stretch to full available width on wide
  displays.
- Section labels in sidebar: dropped :global() wrapper since the
  element is in the same component. Added font-mono for tighter
  letterspacing match to the mockup.

Build clean.
2026-04-25 01:46:16 +03:00

1369 lines
44 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, parseDate } from '$lib/api';
import { t } from '$lib/i18n';
import {
providersCache,
notificationTrackersCache,
targetsCache,
} from '$lib/stores/caches.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import NavIcon from '$lib/components/NavIcon.svelte';
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
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([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })),
]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
let error = $state('');
let displayProviders = $state(0);
let displayActive = $state(0);
let displayTotal = $state(0);
let displayTargets = $state(0);
let displayCommandTrackers = $state(0);
// Event filters
let filterEventType = $state('');
let filterProviderId = $state('');
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
let filterSearch = $state('');
let filterSort = $state('newest');
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 confirmClearEvents = $state(false);
async function clearEvents() {
try {
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
eventsOffset = 0;
await loadEvents();
await loadChart();
} catch (err: unknown) {
snackError(err instanceof Error ? err.message : t('common.error'));
} finally {
confirmClearEvents = false;
}
}
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
if (to === 0) { setter(0); return; }
const start = performance.now();
function frame(now: number) {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setter(Math.round(from + (to - from) * eased));
if (progress < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
/** Build filter query string (shared by events list + chart). */
function buildFilterParams(): URLSearchParams {
const params = new URLSearchParams();
if (filterEventType) params.set('event_type', filterEventType);
if (effectiveProviderId) params.set('provider_id', effectiveProviderId);
if (filterSearch) params.set('search', filterSearch);
return params;
}
async function loadEvents() {
eventsLoading = true;
try {
const params = buildFilterParams();
params.set('sort', filterSort);
params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset));
const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
eventsLoading = false;
}
}
async function loadChart() {
try {
const params = buildFilterParams();
const qs = params.toString();
const chartRes = await api<{ days: { date: string; [k: string]: string | number }[] }>(`/status/chart${qs ? '?' + qs : ''}`);
chartDays = chartRes.days || [];
} catch (e) { console.warn('Failed to load chart data:', e); }
}
// Auto-apply when filter values change (via IconGridSelect bind:value)
let _prevFilterKey = '';
$effect(() => {
const key = `${filterEventType}|${effectiveProviderId}|${filterSort}`;
if (loaded && key !== _prevFilterKey && _prevFilterKey !== '') {
applyFilters();
}
_prevFilterKey = key;
});
function applyFilters() {
eventsOffset = 0;
loadEvents();
loadChart();
}
function goToPage(page: number) {
eventsOffset = (page - 1) * eventsLimit;
loadEvents();
}
let searchTimeout: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
}
onMount(() => {
loadInitial();
return () => {
clearTimeout(searchTimeout);
};
});
async function loadInitial() {
try {
const [statusRes, , chartRes] = await Promise.all([
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<{ days: { date: string; [k: string]: string | number }[] }>('/status/chart'),
]);
status = statusRes;
chartDays = chartRes.days || [];
setTimeout(() => {
if (!status) return;
animateCount(0, status.providers, (v) => displayProviders = v);
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
animateCount(0, status.targets, (v) => displayTargets = v);
if (status.command_trackers !== undefined) {
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
}
}, 200);
} catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error');
} finally {
loaded = true;
}
}
const filteredProviderCount = $derived(globalProviderFilter.providerType
? providers.filter(p => p.type === globalProviderFilter.providerType).length
: displayProviders);
// === Provider deck — derive activity counts from recent events ===
const providerEventCounts = $derived.by(() => {
const counts = new Map<string, number>();
if (!status) return counts;
for (const ev of status.recent_events) {
const k = ev.provider_name || '';
if (!k) continue;
counts.set(k, (counts.get(k) || 0) + (ev.assets_count || 1));
}
return counts;
});
const providerDeck = $derived.by(() => {
const max = Math.max(1, ...Array.from(providerEventCounts.values()));
return providers.map(p => {
const trackers = notificationTrackersCache.items.filter(t => t.provider_id === p.id);
const events = providerEventCounts.get(p.name) || 0;
return {
id: p.id,
name: p.name,
type: p.type,
icon: providerDefaultIcon(p),
trackerCount: trackers.length,
armedCount: trackers.filter(t => (t as { enabled?: boolean }).enabled !== false).length,
events,
share: events / max,
descriptor: getDescriptor(p.type),
};
}).sort((a, b) => b.events - a.events);
});
// === Active wires — derive top tracker → target routes ===
const activeWires = $derived.by(() => {
const wires: Array<{
trackerName: string;
providerName: string;
providerType: string;
providerIcon: string;
targetName: string;
targetType: string;
targetIcon: string;
events: number;
}> = [];
const targetsById = new Map(targetsCache.items.map(tg => [tg.id, tg]));
const trackerEventCount = new Map<string, number>();
if (status) {
for (const ev of status.recent_events) {
const k = ev.tracker_name || '';
if (!k) continue;
trackerEventCount.set(k, (trackerEventCount.get(k) || 0) + (ev.assets_count || 1));
}
}
for (const tracker of notificationTrackersCache.items) {
const provider = providers.find(p => p.id === tracker.provider_id);
if (!provider) continue;
const links = (tracker as { tracker_targets?: { target_id: number }[] }).tracker_targets || [];
for (const link of links) {
const target = targetsById.get(link.target_id);
if (!target) continue;
wires.push({
trackerName: tracker.name,
providerName: provider.name,
providerType: provider.type,
providerIcon: providerDefaultIcon(provider),
targetName: target.name,
targetType: target.type,
targetIcon: target.icon || `mdi${target.type[0].toUpperCase() + target.type.slice(1)}`,
events: trackerEventCount.get(tracker.name) || 0,
});
}
}
return wires.sort((a, b) => b.events - a.events).slice(0, 6);
});
// === Hero summary sentence === */
const heroSummary = $derived.by(() => {
if (!status) return null;
const failing = status.recent_events.filter(e => /fail|error/.test(e.event_type)).length;
return {
armed: status.trackers.active,
total: status.trackers.total,
providers: status.providers,
targets: status.targets,
throughput: status.total_events,
failing,
allOk: failing === 0,
};
});
function timeAgo(dateStr: string): string {
const diff = Date.now() - parseDate(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return t('dashboard.justNow');
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
const hours = Math.floor(mins / 60);
if (hours < 24) return t('dashboard.hoursAgo').replace('{n}', String(hours));
return t('dashboard.daysAgo').replace('{n}', String(Math.floor(hours / 24)));
}
function timeShort(dateStr: string): string {
const d = parseDate(dateStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
const eventLabels: Record<string, string> = {
assets_added: 'dashboard.assetsAdded',
assets_removed: 'dashboard.assetsRemoved',
collection_renamed: 'dashboard.collectionRenamed',
collection_deleted: 'dashboard.collectionDeleted',
sharing_changed: 'dashboard.sharingChanged',
scheduled_message: 'dashboard.scheduledMessage',
action_success: 'dashboard.actionSuccess',
action_partial: 'dashboard.actionPartial',
action_failed: 'dashboard.actionFailed',
};
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
scheduled_message: 'mdiCalendarClock',
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
};
// Aurora gradient palette per event type — used for the avatar tile
const eventGradients: Record<string, [string, string]> = {
assets_added: ['var(--color-mint)', 'var(--color-sky)'],
assets_removed: ['var(--color-coral)', 'var(--color-orchid)'],
collection_renamed: ['var(--color-primary)', 'var(--color-orchid)'],
collection_deleted: ['var(--color-coral)', 'var(--color-citrus)'],
sharing_changed: ['var(--color-citrus)', 'var(--color-coral)'],
scheduled_message: ['var(--color-sky)', 'var(--color-mint)'],
action_success: ['var(--color-mint)', 'var(--color-primary)'],
action_partial: ['var(--color-citrus)', 'var(--color-orchid)'],
action_failed: ['var(--color-coral)', 'var(--color-orchid)'],
};
const STAT_ACCENTS = [
'var(--color-primary)',
'var(--color-sky)',
'var(--color-citrus)',
'var(--color-orchid)',
];
</script>
{#if !loaded}
<Loading lines={4} />
{:else if error}
<div class="hero-card error-card">
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
<MdiIcon name="mdiAlertCircle" size={20} />
<p class="text-sm">{error}</p>
</div>
</div>
{:else if status && heroSummary}
<!-- ==================== HERO ==================== -->
<section class="hero-card">
<div class="hero-row">
<div class="hero-main">
<div class="hero-crumb">
<span>{t('dashboard.title')}</span>
<span class="sep">·</span>
<span class="live">
<span class="live-dot"></span>
{heroSummary.allOk ? t('dashboard.live') : t('dashboard.attention')}
</span>
</div>
<h1 class="hero-title">
{t('dashboard.heroPrefix')} <em>{t('dashboard.heroEmphasis')}</em><br>
{t('dashboard.heroSuffix')}
</h1>
<p class="hero-sub">
{t('dashboard.heroSummary')
.replace('{providers}', String(heroSummary.providers))
.replace('{armed}', String(heroSummary.armed))
.replace('{total}', String(heroSummary.total))
.replace('{throughput}', String(heroSummary.throughput))
.replace('{targets}', String(heroSummary.targets))}
</p>
</div>
<div class="hero-meter">
<div class="hero-meter-label">{t('dashboard.throughput24h')}</div>
<div class="hero-meter-value font-mono">{heroSummary.throughput.toLocaleString()}<sup>{t('dashboard.eventsShort')}</sup></div>
<div class="hero-meter-row">
<span class="pill"><span class="dot" style="background: var(--color-mint)"></span><b>{heroSummary.armed}/{heroSummary.total}</b> {t('dashboard.armedShort')}</span>
<span class="pill"><span class="dot" style="background: var(--color-sky)"></span><b>{heroSummary.providers}</b> {t('dashboard.providersShort')}</span>
<span class="pill"><span class="dot" style="background: var(--color-orchid)"></span><b>{heroSummary.targets}</b> {t('dashboard.targetsShort')}</span>
</div>
</div>
</div>
</section>
<!-- ==================== STATS ==================== -->
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
<div class="stat-card" style="--accent: {card.accent}">
<div class="stat-card-inner">
<div class="flex items-center gap-3">
<div class="stat-icon" style="color: {card.accent};">
<MdiIcon name={card.icon} size={20} />
</div>
<div class="min-w-0">
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {idx * 80 + 200}ms;">
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
</div>
</div>
</div>
</div>
{/snippet}
{#snippet statCards()}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{#if globalProviderFilter.provider}
{@render statCardSnippet({
icon: providerDefaultIcon(globalProviderFilter.provider),
label: '',
literalLabel: getDescriptor(globalProviderFilter.provider.type)?.defaultName ?? globalProviderFilter.provider.type,
value: 0,
literalValue: globalProviderFilter.provider.name,
accent: STAT_ACCENTS[0],
}, 0)}
{:else}
{@render statCardSnippet({
icon: 'mdiServer',
label: 'dashboard.providers',
value: filteredProviderCount,
accent: STAT_ACCENTS[0],
}, 0)}
{/if}
{@render statCardSnippet({
icon: 'mdiRadar',
label: 'dashboard.activeTrackers',
value: displayActive,
suffix: ` / ${displayTotal}`,
accent: STAT_ACCENTS[1],
}, 1)}
{@render statCardSnippet({
icon: 'mdiTarget',
label: 'dashboard.targets',
value: displayTargets,
accent: STAT_ACCENTS[2],
}, 2)}
{#if status?.command_trackers !== undefined}
{@render statCardSnippet({
icon: 'mdiConsoleLine',
label: 'nav.commandTrackers',
value: displayCommandTrackers,
accent: STAT_ACCENTS[3],
}, 3)}
{:else}
{@render statCardSnippet({
icon: 'mdiPulse',
label: 'dashboard.eventsTotal',
value: heroSummary?.throughput ?? 0,
accent: STAT_ACCENTS[3],
}, 3)}
{/if}
</div>
{/snippet}
{@render statCards()}
<!-- ==================== TWO COL: stream + provider deck ==================== -->
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
<!-- Signal stream -->
<section class="panel">
<header class="panel-head">
<div>
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
<p class="panel-meta">
<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>
</button>
{/if}
</header>
<div class="panel-filters">
<div class="flex-1 min-w-[150px] max-w-[280px]">
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
placeholder={t('dashboard.searchEvents')}
class="search-input" />
</div>
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
</div>
{#snippet paginator()}
<div class="flex items-center justify-center gap-1">
{#if totalPages > 1}
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
class="paginator-btn">
<NavIcon name="mdiChevronLeft" size={14} />
</button>
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as p}
{#if p === 1 || p === totalPages || (p >= currentPage - 1 && p <= currentPage + 1)}
<button onclick={() => goToPage(p)}
class="paginator-num font-mono {p === currentPage ? 'active' : ''}">
{p}
</button>
{:else if p === currentPage - 2 || p === currentPage + 2}
<span class="paginator-ellipsis"></span>
{/if}
{/each}
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
class="paginator-btn">
<NavIcon name="mdiChevronRight" size={14} />
</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="paginator-select">
{#each [5, 10, 20, 50] as n}
<option value={n}>{n}</option>
{/each}
</select>
</div>
{/snippet}
{#if eventsLoading}
<div class="empty-state"><p>{t('dashboard.loadingEvents')}</p></div>
{:else if status.recent_events.length === 0}
<div class="empty-state">
<MdiIcon name="mdiCalendarBlank" size={36} />
<p>{t('dashboard.noEvents')}</p>
</div>
{:else}
<div class="signal-list stagger-children">
{#each status.recent_events as event, i}
<div class="signal-row" style="animation-delay: {i * 60}ms;">
<div class="signal-avatar"
style="--g1: {eventGradients[event.event_type]?.[0] ?? 'var(--color-primary)'}; --g2: {eventGradients[event.event_type]?.[1] ?? 'var(--color-orchid)'};">
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={18} />
</div>
<div class="signal-body min-w-0">
<div class="signal-sentence">
{#if event.provider_name}<b class="signal-emph">{event.provider_name}</b>{/if}
<span class="signal-verb">{t(eventLabels[event.event_type] || event.event_type)}</span>
{#if event.assets_count > 0}
<b class="signal-emph">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</b>
{/if}
{#if event.collection_name}
<span class="signal-conn">·</span>
<b class="signal-emph signal-emph--accent truncate">«{event.collection_name}»</b>
{/if}
</div>
{#if event.tracker_name}
<div class="signal-trail">
<span class="ch"><MdiIcon name="mdiRadar" size={11} />{event.tracker_name}</span>
{#if event.provider_name}
<span class="arrow"></span>
<span class="ch"><MdiIcon name="mdiServer" size={11} />{event.provider_name}</span>
{/if}
</div>
{/if}
</div>
<div class="signal-when font-mono">
<b>{timeShort(event.created_at)}</b>
<small>{timeAgo(event.created_at)}</small>
</div>
</div>
{/each}
</div>
<div class="panel-foot">
{@render paginator()}
</div>
{/if}
</section>
<!-- On watch — provider deck. Hidden when a global provider filter is
active (the deck would only show that one provider — redundant). -->
{#if !globalProviderFilter.id}
<section class="panel">
<header class="panel-head">
<div>
<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>
</header>
{#if providerDeck.length === 0}
<div class="empty-state">
<MdiIcon name="mdiServerNetwork" size={32} />
<p>{t('dashboard.noProviders')}</p>
<a href="/providers" class="empty-state-link">{t('dashboard.addProvider')}</a>
</div>
{:else}
<div class="provider-deck">
{#each providerDeck as p}
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}">
<div class="provider-icon">
<MdiIcon name={p.icon} size={20} />
</div>
<div class="min-w-0 flex-1">
<div class="provider-name truncate">
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
{p.name}
</div>
<div class="provider-sub font-mono">
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
</div>
</div>
<div class="provider-meter">
<div class="provider-num font-mono">{p.events}</div>
<div class="provider-bar"><div class="provider-bar-fill" style="width: {Math.max(6, p.share * 100)}%"></div></div>
</div>
</a>
{/each}
<a href="/providers/new" class="provider-row provider-row--add">
<div class="provider-icon dashed">
<NavIcon name="mdiPlus" size={18} />
</div>
<div class="min-w-0 flex-1">
<div class="provider-name muted">{t('dashboard.addProvider')}</div>
<div class="provider-sub font-mono">{t('dashboard.addProviderHint')}</div>
</div>
</a>
</div>
{/if}
</section>
{/if}
</div>
<!-- ==================== PULSE CHART ==================== -->
<section class="panel">
<header class="panel-head">
<div>
<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}
class="ghost-icon-btn"
title={chartVisible ? t('common.hide') : t('common.show')}>
<NavIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={16} />
</button>
</header>
{#if chartVisible}
<div class="panel-body" in:slide={{ duration: 200 }}>
<EventChart days={chartDays} />
</div>
{/if}
</section>
<!-- ==================== ACTIVE WIRES ==================== -->
{#if activeWires.length > 0}
<section class="panel">
<header class="panel-head">
<div>
<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>
</header>
<div class="wires-body">
{#each activeWires as wire}
<div class="wire">
<div class="wire-from">
<MdiIcon name={wire.providerIcon} size={15} />
<div class="min-w-0">
<div class="wire-name truncate">{wire.trackerName}</div>
<div class="wire-sub font-mono truncate">{wire.providerName}</div>
</div>
</div>
<div class="wire-pipe">
<span class="wire-count font-mono">{wire.events > 0 ? wire.events : '—'}</span>
</div>
<div class="wire-to">
<div class="min-w-0">
<div class="wire-name truncate">{wire.targetName}</div>
<div class="wire-sub font-mono truncate">{wire.targetType}</div>
</div>
<MdiIcon name={wire.targetIcon} size={15} />
</div>
</div>
{/each}
</div>
</section>
{/if}
<!-- ==================== COMPOSE BAND ==================== -->
<section class="compose-band">
<div class="compose-body">
<h3 class="compose-title">
{t('dashboard.composeTitle')} <em>{t('dashboard.composeEmphasis')}</em>
</h3>
<p class="compose-sub">{t('dashboard.composeSub')}</p>
</div>
<div class="compose-cta">
<a href="/notification-trackers" class="ghost-btn">
<NavIcon name="mdiRadar" size={14} />
{t('dashboard.viewTrackers')}
</a>
<a href="/notification-trackers" class="primary-btn-aurora">
<NavIcon name="mdiArrowRight" size={14} />
{t('dashboard.newTracker')}
</a>
</div>
</section>
{/if}
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
<style>
/* ============================================================
HERO
============================================================ */
.hero-card {
position: relative;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
border-radius: 22px;
box-shadow: var(--shadow-card);
padding: 2rem 2.4rem;
margin-bottom: 1.5rem;
overflow: hidden;
}
.hero-card::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.error-card { padding: 1rem 1.4rem; }
.hero-row {
position: relative; z-index: 1;
display: grid;
grid-template-columns: 1fr auto;
gap: 2rem;
align-items: end;
}
.hero-crumb {
display: flex; align-items: center; gap: 0.6rem;
font-size: 0.78rem;
color: var(--color-muted-foreground);
margin-bottom: 0.85rem;
font-weight: 500;
}
.hero-crumb .sep { color: var(--color-rule-strong); }
.hero-crumb .live {
display: inline-flex; align-items: center; gap: 0.4rem;
color: var(--color-mint);
}
.live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--color-mint);
box-shadow: 0 0 8px var(--color-mint);
animation: aurora-pulse 1.4s ease-in-out infinite;
}
.hero-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 3rem;
line-height: 1;
letter-spacing: -0.025em;
margin: 0 0 0.85rem;
color: var(--color-foreground);
}
.hero-title em {
font-style: italic;
background: linear-gradient(135deg, var(--color-orchid), var(--color-primary) 60%, var(--color-sky));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-sub {
font-size: 0.95rem;
color: var(--color-muted-foreground);
max-width: 36rem;
line-height: 1.55;
}
.hero-meter { text-align: right; }
.hero-meter-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.13em;
color: var(--color-muted-foreground);
margin-bottom: 0.55rem;
font-weight: 500;
}
.hero-meter-value {
font-size: 3rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.03em;
color: var(--color-foreground);
}
.hero-meter-value sup {
font-size: 0.75rem;
color: var(--color-muted-foreground);
margin-left: 0.4rem;
font-weight: 400;
background: var(--color-glass-strong);
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
vertical-align: middle;
font-family: var(--font-sans);
}
.hero-meter-row {
margin-top: 0.85rem;
display: flex; gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
font-size: 0.72rem;
color: var(--color-muted-foreground);
}
.pill b {
color: var(--color-foreground);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.pill .dot { width: 6px; height: 6px; border-radius: 50%; }
@media (max-width: 880px) {
.hero-card { padding: 1.4rem 1.4rem 1.6rem; }
.hero-row { grid-template-columns: 1fr; }
.hero-title { font-size: 2.2rem; }
.hero-meter { text-align: left; }
.hero-meter-row { justify-content: flex-start; }
.hero-meter-value { font-size: 2.4rem; }
}
/* ============================================================
STAT CARDS — same pattern as foundation pass
============================================================ */
.stat-card {
position: relative;
border-radius: 22px;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
overflow: hidden;
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
cursor: pointer;
}
.stat-card::before {
content: '';
position: absolute;
width: 220px; height: 220px;
border-radius: 50%;
right: -100px; top: -90px;
background: radial-gradient(circle, var(--accent) 0%, transparent 70%);
opacity: 0.35;
pointer-events: none;
filter: blur(20px);
transition: transform 0.4s;
}
.stat-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.stat-card:hover { transform: translateY(-3px); }
.stat-card:hover::before { transform: scale(1.15); opacity: 0.55; }
.stat-card-inner { position: relative; z-index: 1; padding: 1.3rem 1.4rem; }
.stat-icon {
display: flex; align-items: center; justify-content: center;
width: 2.5rem; height: 2.5rem;
border-radius: 0.85rem;
flex-shrink: 0;
background: var(--color-glass-elev) !important;
border: 1px solid var(--color-border);
box-shadow: inset 0 1px 0 var(--color-highlight);
}
.stat-value {
font-size: 1.85rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.025em;
font-variant-numeric: tabular-nums;
animation: countUp 0.5s ease-out both;
}
.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); }
/* ============================================================
TWO COL — stream + provider deck
============================================================ */
.two-col {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 1.5rem;
margin-top: 1.5rem;
}
.two-col.two-col--single { grid-template-columns: 1fr; }
@media (max-width: 1100px) { .two-col { grid-template-columns: 1fr; } }
/* ============================================================
PANEL — generic glass panel for all sections
============================================================ */
.panel {
position: relative;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
border-radius: 22px;
box-shadow: var(--shadow-card);
overflow: hidden;
margin-top: 1.5rem;
}
.panel::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.two-col > .panel { margin-top: 0; }
.panel-head {
position: relative; z-index: 1;
display: flex; align-items: flex-start; justify-content: space-between;
padding: 1.25rem 1.5rem 0.85rem;
gap: 1rem;
}
.panel-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.35rem;
letter-spacing: -0.02em;
color: var(--color-foreground);
margin: 0;
line-height: 1.1;
}
.panel-title em {
font-style: italic;
background: linear-gradient(135deg, var(--color-orchid), var(--color-primary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.panel-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
}
.panel-meta b {
color: var(--color-foreground);
font-weight: 500;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
margin-right: 0.25rem;
}
.panel-body {
position: relative; z-index: 1;
padding: 0.5rem 1.5rem 1.5rem;
}
.panel-foot {
position: relative; z-index: 1;
padding: 0.85rem 1.5rem 1.25rem;
border-top: 1px solid var(--color-border);
}
.panel-filters {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 0.5rem;
padding: 0.5rem 1.5rem 0.85rem;
border-bottom: 1px solid var(--color-border);
align-items: center;
}
.search-input {
width: 100%;
padding: 0.5rem 0.85rem;
border-radius: 10px;
background: var(--color-input-bg);
border: 1px solid var(--color-border);
color: var(--color-foreground);
font-size: 0.85rem;
}
/* Empty state */
.empty-state {
position: relative; z-index: 1;
padding: 3rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: var(--color-muted-foreground);
text-align: center;
}
.empty-state p { font-size: 0.85rem; }
.empty-state-link {
font-size: 0.85rem;
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
margin-top: 0.5rem;
}
.empty-state-link:hover { text-decoration: underline; text-underline-offset: 4px; }
/* ============================================================
SIGNAL STREAM — events with routing trail
============================================================ */
.signal-list { position: relative; z-index: 1; padding-bottom: 0.25rem; }
.signal-row {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 0.85rem;
align-items: center;
padding: 0.85rem 1.5rem;
transition: background 0.15s;
cursor: pointer;
}
.signal-row + .signal-row { border-top: 1px solid var(--color-border); }
.signal-row:hover { background: var(--color-glass-strong); }
.signal-avatar {
width: 40px; height: 40px;
border-radius: 12px;
display: grid; place-items: center;
background: linear-gradient(135deg, var(--g1, var(--color-primary)), var(--g2, var(--color-orchid)));
color: white;
box-shadow: 0 4px 14px -4px var(--g1, var(--color-primary)), inset 0 1px 0 rgba(255,255,255,0.3);
}
.signal-body { min-width: 0; }
.signal-sentence {
display: flex; align-items: baseline;
gap: 0.4rem;
font-size: 0.92rem;
line-height: 1.45;
color: var(--color-foreground);
flex-wrap: wrap;
}
.signal-emph {
font-weight: 600;
color: var(--color-foreground);
}
.signal-emph--accent {
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 600;
max-width: 24ch;
}
.signal-verb {
font-weight: 400;
color: var(--color-muted-foreground);
}
.signal-conn {
color: var(--color-rule-strong);
}
.signal-trail {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.72rem;
color: var(--color-muted-foreground);
margin-top: 0.35rem;
flex-wrap: wrap;
}
.signal-trail .ch {
display: inline-flex; align-items: center; gap: 0.3rem;
color: var(--color-foreground);
font-family: var(--font-mono);
font-size: 0.7rem;
background: var(--color-glass-strong);
padding: 0.15rem 0.45rem;
border-radius: 6px;
}
.signal-trail .arrow { color: var(--color-muted-foreground); }
.signal-when {
text-align: right;
font-size: 0.7rem;
color: var(--color-muted-foreground);
}
.signal-when b {
display: block;
color: var(--color-foreground);
font-size: 0.82rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.signal-when small { font-size: 0.65rem; color: var(--color-muted-foreground); }
.clear-events-btn {
color: var(--color-muted-foreground);
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
}
.clear-events-btn:hover {
background: var(--color-error-bg);
border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border));
color: var(--color-error-fg);
}
/* Paginator */
.paginator-btn {
padding: 0.3rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.paginator-btn:hover { background: var(--color-glass-elev); }
.paginator-btn:disabled { opacity: 0.4; cursor: default; }
.paginator-num {
padding: 0.3rem 0.65rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s;
font-size: 0.75rem;
}
.paginator-num:hover { color: var(--color-foreground); background: var(--color-glass-strong); }
.paginator-num.active {
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: white;
border-color: transparent;
box-shadow: 0 4px 12px -4px var(--color-glow-strong);
}
.paginator-ellipsis { padding: 0 0.4rem; color: var(--color-muted-foreground); font-size: 0.7rem; }
.paginator-select {
margin-left: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-input-bg);
color: var(--color-foreground);
font-size: 0.7rem;
}
/* ============================================================
PROVIDER DECK
============================================================ */
.provider-deck { position: relative; z-index: 1; padding-bottom: 0.5rem; }
.provider-row {
display: grid;
grid-template-columns: 44px 1fr auto;
gap: 0.85rem;
align-items: center;
padding: 0.85rem 1.5rem;
text-decoration: none;
color: inherit;
transition: background 0.15s;
}
.provider-row + .provider-row { border-top: 1px solid var(--color-border); }
.provider-row:hover { background: var(--color-glass-strong); }
.provider-icon {
width: 44px; height: 44px;
border-radius: 14px;
display: grid; place-items: center;
background: var(--color-glass-elev);
border: 1px solid var(--color-border);
color: var(--accent, var(--color-primary));
box-shadow: inset 0 1px 0 var(--color-highlight);
}
.provider-icon.dashed {
border-style: dashed;
color: var(--color-muted-foreground);
background: transparent;
box-shadow: none;
}
.provider-name {
font-size: 0.92rem;
font-weight: 500;
color: var(--color-foreground);
display: flex; align-items: center; gap: 0.5rem;
}
.provider-name.muted { color: var(--color-muted-foreground); }
.provider-sub {
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-top: 0.2rem;
}
.provider-meter {
text-align: right;
min-width: 80px;
}
.provider-num {
font-size: 1rem;
color: var(--color-foreground);
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.provider-bar {
margin-top: 0.4rem;
height: 4px;
border-radius: 2px;
background: var(--color-glass-strong);
overflow: hidden;
}
.provider-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent, var(--color-primary)), var(--color-orchid));
border-radius: 2px;
box-shadow: 0 0 8px -2px var(--accent, var(--color-primary));
}
.provider-row--add { opacity: 0.7; }
.provider-row--add:hover { opacity: 1; }
/* ============================================================
ACTIVE WIRES
============================================================ */
.wires-body {
position: relative; z-index: 1;
padding: 0.4rem 1.5rem 1.25rem;
}
.wire {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.85rem;
align-items: center;
padding: 0.7rem 0;
}
.wire + .wire { border-top: 1px dashed var(--color-border); }
.wire-from, .wire-to {
display: flex; align-items: center; gap: 0.6rem;
font-size: 0.85rem;
min-width: 0;
}
.wire-to { justify-content: flex-end; text-align: right; }
.wire-from, .wire-to { color: var(--color-muted-foreground); }
.wire-from .wire-name, .wire-to .wire-name { color: var(--color-foreground); }
.wire-name { color: var(--color-foreground); font-weight: 500; }
.wire-sub { font-size: 0.65rem; color: var(--color-muted-foreground); margin-top: 0.15rem; }
.wire-pipe {
position: relative;
min-width: 100px;
display: flex; align-items: center; justify-content: center;
height: 22px;
}
.wire-pipe::before {
content: '';
position: absolute; inset: 50% 0 auto 0; height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-orchid), var(--color-mint));
opacity: 0.5;
border-radius: 2px;
}
.wire-count {
position: relative; z-index: 1;
background: var(--color-glass-elev);
border: 1px solid var(--color-border);
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.7rem;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
/* ============================================================
COMPOSE BAND
============================================================ */
.compose-band {
position: relative;
margin-top: 1.5rem;
padding: 2rem 2.4rem;
border-radius: 22px;
background:
linear-gradient(135deg, rgba(126, 232, 196, 0.10), rgba(142, 201, 255, 0.10), rgba(184, 167, 255, 0.12)),
var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-card);
display: grid;
grid-template-columns: 1fr auto;
gap: 2rem;
align-items: center;
overflow: hidden;
}
.compose-band::after {
content: '';
position: absolute; inset: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
opacity: 0.4;
}
.compose-body { position: relative; z-index: 1; }
.compose-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.85rem;
line-height: 1.1;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
color: var(--color-foreground);
}
.compose-title em {
font-style: italic;
background: linear-gradient(135deg, var(--color-mint), var(--color-sky));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.compose-sub {
font-size: 0.9rem;
color: var(--color-muted-foreground);
max-width: 36rem;
line-height: 1.55;
}
.compose-cta {
position: relative; z-index: 1;
display: flex; gap: 0.6rem;
}
.ghost-btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0 1rem; height: 40px;
border-radius: 12px;
border: 1px solid var(--color-rule-strong);
background: var(--color-glass-strong);
color: var(--color-foreground);
font-size: 0.82rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
}
.ghost-btn:hover { background: var(--color-glass-elev); }
.primary-btn-aurora {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0 1.25rem; height: 40px;
border-radius: 12px;
border: 0;
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: white;
font-size: 0.82rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
flex-direction: row-reverse;
box-shadow: 0 6px 20px -8px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.3);
transition: transform 0.15s;
}
.primary-btn-aurora:hover { transform: translateY(-1px); }
.ghost-icon-btn {
width: 32px; height: 32px;
display: grid; place-items: center;
border-radius: 10px;
background: var(--color-glass-strong);
border: 1px solid var(--color-border);
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s;
}
.ghost-icon-btn:hover { background: var(--color-glass-elev); color: var(--color-foreground); }
@media (max-width: 880px) {
.compose-band { grid-template-columns: 1fr; padding: 1.4rem; }
.compose-title { font-size: 1.4rem; }
}
</style>