a7a2b4efa4
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
426 lines
19 KiB
Svelte
426 lines
19 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 } from '$lib/stores/caches.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EventChart from '$lib/components/EventChart.svelte';
|
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.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);
|
|
|
|
// 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: any) {
|
|
snackError(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: any) {
|
|
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: any) {
|
|
error = err.message || t('common.error');
|
|
} finally {
|
|
loaded = true;
|
|
}
|
|
}
|
|
|
|
let displayCommandTrackers = $state(0);
|
|
|
|
const filteredProviderCount = $derived(globalProviderFilter.providerType
|
|
? providers.filter(p => p.type === globalProviderFilter.providerType).length
|
|
: displayProviders);
|
|
|
|
const providerCard = $derived.by(() => {
|
|
const gp = globalProviderFilter.provider;
|
|
if (gp) {
|
|
const desc = getDescriptor(gp.type);
|
|
return { icon: providerDefaultIcon(gp), label: '', literalLabel: desc?.defaultName ?? gp.type, value: 0, literalValue: gp.name, color: '#0d9488' };
|
|
}
|
|
return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' };
|
|
});
|
|
|
|
interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string }
|
|
const statCards = $derived<StatCard[]>(status ? [
|
|
providerCard,
|
|
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
|
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
|
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
|
|
] : []);
|
|
|
|
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)));
|
|
}
|
|
|
|
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',
|
|
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',
|
|
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
|
};
|
|
const eventColors: Record<string, string> = {
|
|
assets_added: '#059669', assets_removed: '#ef4444',
|
|
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
|
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
|
};
|
|
|
|
</script>
|
|
|
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
|
|
|
{#if !loaded}
|
|
<Loading lines={4} />
|
|
{:else if 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>
|
|
</Card>
|
|
{:else if status}
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
|
{#each statCards as card, i}
|
|
<div class="stat-card" style="--accent: {card.color};">
|
|
<div class="stat-card-inner">
|
|
<div class="flex items-center gap-3">
|
|
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
|
<MdiIcon name={card.icon} size={22} />
|
|
</div>
|
|
<div>
|
|
<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: {i * 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>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Events section -->
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
|
<MdiIcon name="mdiPulse" size={18} />
|
|
{t('dashboard.recentEvents')}
|
|
{#if status.total_events > 0}
|
|
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
|
{/if}
|
|
</h3>
|
|
{#if status.total_events > 0}
|
|
<button type="button" onclick={() => confirmClearEvents = true}
|
|
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
|
|
title={t('dashboard.clearEvents')}>
|
|
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
|
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
|
<div class="flex-1 min-w-[150px] max-w-[260px]">
|
|
<input type="text" bind:value={filterSearch} oninput={onSearchInput}
|
|
placeholder={t('dashboard.searchEvents')}
|
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</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>
|
|
|
|
<!-- Chart -->
|
|
<button type="button" onclick={toggleChart}
|
|
class="flex items-center gap-1.5 text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors mb-2 cursor-pointer">
|
|
<MdiIcon name={chartVisible ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
|
<MdiIcon name="mdiChartBar" size={14} />
|
|
{t('dashboard.chart')}
|
|
</button>
|
|
{#if chartVisible}
|
|
<div in:slide={{ duration: 200 }}>
|
|
<EventChart days={chartDays} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet paginator()}
|
|
<div class="flex items-center justify-center gap-1">
|
|
{#if totalPages > 1}
|
|
<button onclick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronLeft" size={16} />
|
|
</button>
|
|
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
|
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
|
<button onclick={() => goToPage(page)}
|
|
class="px-2.5 py-1 text-xs font-mono rounded-md border transition-colors {page === currentPage
|
|
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border-[var(--color-primary)]'
|
|
: 'border-[var(--color-border)] hover:bg-[var(--color-muted)]'}">
|
|
{page}
|
|
</button>
|
|
{:else if page === currentPage - 2 || page === currentPage + 2}
|
|
<span class="px-1 text-xs text-[var(--color-muted-foreground)]">...</span>
|
|
{/if}
|
|
{/each}
|
|
<button onclick={() => goToPage(currentPage + 1)} disabled={currentPage >= totalPages}
|
|
class="px-2 py-1 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 disabled:cursor-default">
|
|
<MdiIcon name="mdiChevronRight" size={16} />
|
|
</button>
|
|
{/if}
|
|
<select value={eventsLimit}
|
|
onchange={(e) => { const v = parseInt((e.target as HTMLSelectElement).value, 10); eventsLimit = v; eventsOffset = 0; if (typeof localStorage !== 'undefined') localStorage.setItem(EVENTS_PER_PAGE_KEY, String(v)); loadEvents(); }}
|
|
class="ml-2 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)] text-[var(--color-foreground)]">
|
|
{#each [5, 10, 20, 50] as n}
|
|
<option value={n}>{n}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if eventsLoading}
|
|
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
|
{:else if status.recent_events.length === 0}
|
|
<Card>
|
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
|
<div style="opacity: 0.4;"><MdiIcon name="mdiCalendarBlank" size={40} /></div>
|
|
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
|
</div>
|
|
</Card>
|
|
{:else}
|
|
<div class="event-timeline stagger-children">
|
|
{#each status.recent_events as event, i}
|
|
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
|
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
|
{#if i < status.recent_events.length - 1}<div class="event-line"></div>{/if}
|
|
<div class="event-content">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<div class="flex items-center gap-2 min-w-0 flex-wrap">
|
|
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
|
</span>
|
|
<span class="text-sm font-medium truncate">{event.collection_name}</span>
|
|
<span class="event-badge">{t(eventLabels[event.event_type] || event.event_type)}</span>
|
|
{#if event.assets_count > 0}
|
|
<span class="event-badge" style="background: {eventColors[event.event_type]}20; color: {eventColors[event.event_type]};">{event.assets_count} {event.assets_count === 1 ? t('dashboard.asset') : t('dashboard.assets')}</span>
|
|
{/if}
|
|
</div>
|
|
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
|
</div>
|
|
{#if event.provider_name || event.tracker_name}
|
|
<div class="flex items-center gap-2 mt-1 text-xs" style="color: var(--color-muted-foreground);">
|
|
{#if event.provider_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiServer" size={12} />{event.provider_name}</span>
|
|
{/if}
|
|
{#if event.tracker_name}
|
|
<span class="flex items-center gap-1"><MdiIcon name="mdiRadar" size={12} />{event.tracker_name}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Bottom paginator -->
|
|
<div class="mt-4">
|
|
{@render paginator()}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
|
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
|
|
|
<style>
|
|
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
|
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
|
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
|
|
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
|
|
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; 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); }
|
|
.event-timeline { display: flex; flex-direction: column; }
|
|
.event-item { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.5rem; }
|
|
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
|
|
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
|
|
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
|
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
|
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
|
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
|
|
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
|
|
</style>
|