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
+80 -33
View File
@@ -1,49 +1,96 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
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 IconButton from '$lib/components/IconButton.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let providers = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
let deleteTarget = $state<any>(null);
onMount(async () => {
try {
providers = await api.get('/providers');
} catch { /* auth redirect handled by api */ }
loading = false;
await loadProviders();
});
async function loadProviders() {
try {
providers = await api('/providers');
} catch (err: any) {
snackError(err.message);
} finally {
loaded = true;
}
}
async function deleteProvider() {
if (!deleteTarget) return;
try {
await api(`/providers/${deleteTarget.id}`, { method: 'DELETE' });
snackSuccess(t('snack.providerDeleted'));
deleteTarget = null;
await loadProviders();
} catch (err: any) {
snackError(err.message);
}
}
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.providers')}</h1>
<a href="/providers/new" class="px-4 py-2 bg-primary text-primary-foreground rounded-[var(--radius)] text-sm font-medium">
{t('provider.addProvider')}
</a>
</div>
<PageHeader title={t('providers.title')} description={t('providers.description')}>
<a href="/providers/new"
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200"
style="background: var(--color-primary); color: var(--color-primary-foreground);"
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
<MdiIcon name="mdiPlus" size={16} />
{t('providers.addProvider')}
</a>
</PageHeader>
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if providers.length === 0}
<div class="text-center py-16 text-muted-foreground">
<p class="text-lg mb-2">{t('common.noData')}</p>
<p class="text-sm">Add your first service provider to get started.</p>
{#if !loaded}
<Loading />
{:else if providers.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="mdiServer" size={40} />
</div>
<p class="text-sm">{t('providers.noProviders')}</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 stagger-children">
{#each providers as provider}
<div class="p-4 bg-card rounded-xl border border-border">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold">
{provider.type[0].toUpperCase()}
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 stagger-children">
{#each providers as provider}
<Card hover>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-primary); color: var(--color-primary-foreground); opacity: 0.9;">
<MdiIcon name={provider.icon || 'mdiServer'} size={20} />
</div>
<div>
<h3 class="font-semibold">{provider.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{provider.type}</p>
<h3 class="font-medium text-sm">{provider.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{provider.type}</p>
</div>
</div>
<IconButton icon="mdiDelete" variant="danger" title={t('providers.delete')}
onclick={() => deleteTarget = provider} />
</div>
{/each}
</div>
{/if}
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal
open={!!deleteTarget}
title={t('providers.confirmDelete')}
message={deleteTarget?.name || ''}
onconfirm={deleteProvider}
oncancel={() => deleteTarget = null}
/>