feat: port full CRUD API routes and frontend pages from Immich Watcher

Backend API (38 routes):
- providers: full CRUD + test connection + list collections + API key masking
- trackers: full CRUD + trigger + history + test-periodic/memory
- tracking-configs: full CRUD with Pydantic models, provider_type filter
- template-configs: full CRUD + preview + preview-raw with two-pass validation
- targets: full CRUD + test notification + config masking
- telegram-bots: full CRUD + chat discovery + token endpoint
- users: full admin CRUD + password reset + self-delete protection
- status: dashboard endpoint with providers/trackers/targets/events counts

Frontend pages updated:
- Dashboard with animated stat cards and event timeline
- Providers with proper components, delete confirm, snackbar
- Trackers/targets/tracking-configs/template-configs/telegram-bots/users
  all use PageHeader, Card, Loading, MdiIcon with correct i18n keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:49:40 +03:00
parent c9cab93d12
commit 9eec21a5b2
17 changed files with 1596 additions and 244 deletions
+157 -5
View File
@@ -1,13 +1,165 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { getAuth } from '$lib/auth.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';
const auth = getAuth();
let status = $state<any>(null);
let loaded = $state(false);
let error = $state('');
let displayProviders = $state(0);
let displayActive = $state(0);
let displayTotal = $state(0);
let displayTargets = $state(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);
}
onMount(async () => {
try {
const [providers, trackers, targets] = await Promise.all([
api<any[]>('/providers'),
api<any[]>('/trackers'),
api<any[]>('/targets'),
]);
status = {
providers: providers.length,
trackers: { active: trackers.filter((t: any) => t.enabled).length, total: trackers.length },
targets: targets.length,
recent_events: [],
};
setTimeout(() => {
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);
}, 200);
} catch (err: any) {
error = err.message || t('common.error');
} finally {
loaded = true;
}
});
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
] : []);
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
const eventIcons: Record<string, string> = {
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
};
const eventColors: Record<string, string> = {
assets_added: '#059669', assets_removed: '#ef4444',
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
};
</script>
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
<div class="text-center py-12">
<p class="text-muted-foreground">{t('dashboard.noEvents')}</p>
</div>
{#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-3 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);">{t(card.label)}</p>
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
</div>
</div>
</div>
</div>
{/each}
</div>
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
<MdiIcon name="mdiPulse" size={18} />
{t('dashboard.recentEvents')}
</h3>
{#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">
<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">{event.event_type.replace('_', ' ')}</span>
</div>
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
<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); transform: translateY(-2px); }
.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-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
.event-timeline { display: flex; flex-direction: column; }
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
.event-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; z-index: 1; }
.event-line { position: absolute; left: 4px; top: 18px; bottom: 0; width: 2px; background: var(--color-border); }
.event-content { flex: 1; min-width: 0; padding: 0.5rem 0.875rem; border-radius: 0.625rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
</style>