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>
+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}
/>
+36 -23
View File
@@ -1,34 +1,47 @@
<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';
let targets = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
onMount(async () => {
try { targets = await api.get('/targets'); } catch {}
loading = false;
try { targets = await api('/targets'); } catch {}
loaded = true;
});
</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.targets')}</h1>
</div>
<PageHeader title={t('targets.title')} description={t('targets.description')} />
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if targets.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each targets as target}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{target.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{target.type}</p>
</div>
{/each}
{#if !loaded}
<Loading />
{:else if targets.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="mdiTarget" size={40} /></div>
<p class="text-sm">{t('targets.noTargets')}</p>
</div>
{/if}
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each targets as target}
<Card hover>
<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={target.type === 'telegram' ? 'mdiTelegram' : 'mdiWebhook'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{target.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{target.type}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
+45 -6
View File
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="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';
let bots = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { bots = await api('/telegram-bots'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.telegramBots')}</h1>
<p class="text-muted-foreground">Telegram bot management — coming soon.</p>
</div>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')} />
{#if !loaded}
<Loading />
{:else if bots.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="mdiRobot" size={40} /></div>
<p class="text-sm">{t('telegramBot.noBots')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each bots as bot}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: #229ED9; color: white;">
<MdiIcon name="mdiRobot" size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{bot.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">@{bot.bot_username || '...'}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
@@ -1,8 +1,51 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="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';
let configs = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { configs = await api('/template-configs'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.templateConfigs')}</h1>
<p class="text-muted-foreground">Template configuration management — coming soon.</p>
</div>
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')} />
{#if !loaded}
<Loading />
{:else if configs.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="mdiFileDocumentEdit" size={40} /></div>
<p class="text-sm">{t('templateConfig.noConfigs')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">{config.description || config.provider_type}</p>
</div>
{#if config.user_id === 0}
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: var(--color-muted); color: var(--color-muted-foreground);">System</span>
{/if}
</div>
</Card>
{/each}
</div>
{/if}
+42 -23
View File
@@ -1,34 +1,53 @@
<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';
let trackers = $state<any[]>([]);
let loading = $state(true);
let loaded = $state(false);
onMount(async () => {
try { trackers = await api.get('/trackers'); } catch {}
loading = false;
try { trackers = await api('/trackers'); } catch {}
loaded = true;
});
</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.trackers')}</h1>
</div>
<PageHeader title={t('trackers.title')} description={t('trackers.description')} />
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if trackers.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each trackers as tracker}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{tracker.name}</h3>
<p class="text-xs text-muted-foreground">{tracker.collection_ids?.length || 0} collections</p>
</div>
{/each}
{#if !loaded}
<Loading />
{:else if trackers.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="mdiRadar" size={40} /></div>
<p class="text-sm">{t('trackers.noTrackers')}</p>
</div>
{/if}
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each trackers as tracker}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
<MdiIcon name="mdiRadar" size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{tracker.name}</h3>
<p class="text-xs" style="color: var(--color-muted-foreground);">
{tracker.collection_ids?.length || 0} {t('trackers.albums_count')} &middot; {t('trackers.every')} {tracker.scan_interval}s
</p>
</div>
<span class="ml-auto text-xs px-2 py-0.5 rounded-full"
style="background: {tracker.enabled ? 'var(--color-success-bg)' : 'var(--color-muted)'}; color: {tracker.enabled ? 'var(--color-success-fg)' : 'var(--color-muted-foreground)'};">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
</span>
</div>
</Card>
{/each}
</div>
{/if}
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="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';
let configs = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { configs = await api('/tracking-configs'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.trackingConfigs')}</h1>
<p class="text-muted-foreground">Tracking configuration management — coming soon.</p>
</div>
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')} />
{#if !loaded}
<Loading />
{:else if configs.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="mdiCog" size={40} /></div>
<p class="text-sm">{t('trackingConfig.noConfigs')}</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each configs as config}
<Card hover>
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg"
style="background: var(--color-accent); color: var(--color-accent-foreground);">
<MdiIcon name={config.icon || 'mdiCog'} size={20} />
</div>
<div>
<h3 class="font-medium text-sm">{config.name}</h3>
<p class="text-xs capitalize" style="color: var(--color-muted-foreground);">{config.provider_type}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}
+45 -6
View File
@@ -1,8 +1,47 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
<script lang="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';
let users = $state<any[]>([]);
let loaded = $state(false);
onMount(async () => {
try { users = await api('/users'); } catch {}
loaded = true;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.users')}</h1>
<p class="text-muted-foreground">User management — coming soon.</p>
</div>
<PageHeader title={t('users.title')} description={t('users.description')} />
{#if !loaded}
<Loading />
{:else if users.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="mdiAccountGroup" size={40} /></div>
<p class="text-sm">No users found.</p>
</div>
</Card>
{:else}
<div class="grid gap-4 sm:grid-cols-2 stagger-children">
{#each users as user}
<Card hover>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style="background: var(--color-primary); color: var(--color-primary-foreground);">
{user.username[0].toUpperCase()}
</div>
<div>
<h3 class="font-medium text-sm">{user.username}</h3>
<p class="text-xs uppercase tracking-wide" style="color: var(--color-muted-foreground);">{user.role}</p>
</div>
</div>
</Card>
{/each}
</div>
{/if}