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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')} · {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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user