1895c5e2d4
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.
1369 lines
44 KiB
Svelte
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>
|