Add SvelteKit frontend with Tailwind CSS (Phase 4)
Some checks failed
Validate / Hassfest (push) Has been cancelled

Build a modern, calm web UI using SvelteKit 5 + Tailwind CSS v4.

Pages:
- Setup wizard (first-run admin account creation)
- Login with JWT token management and auto-refresh
- Dashboard with stats cards and recent events timeline
- Servers: add/delete Immich server connections with validation
- Trackers: create album trackers with album picker, event type
  selection, target assignment, and scan interval config
- Templates: Jinja2 message template editor with live preview
- Targets: Telegram and webhook notification targets with test
- Users: admin-only user management (create/delete)

Architecture:
- Reactive auth state with Svelte 5 runes
- API client with JWT auth, auto-refresh on 401
- Static adapter builds to 153KB for embedding in FastAPI
- Vite proxy config for dev server -> backend API
- Sidebar layout with navigation and user info

Also adds Rule 2 to primary plan: perform detailed code review
after completing each phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:46:55 +03:00
parent 58b2281dc6
commit 87ce1bc5ec
29 changed files with 4891 additions and 2 deletions

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
let { children } = $props();
const auth = getAuth();
const navItems = [
{ href: '/', label: 'Dashboard', icon: '⊞' },
{ href: '/servers', label: 'Servers', icon: '⬡' },
{ href: '/trackers', label: 'Trackers', icon: '◎' },
{ href: '/templates', label: 'Templates', icon: '⎘' },
{ href: '/targets', label: 'Targets', icon: '◇' },
];
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
onMount(async () => {
await loadUser();
if (!auth.user && !isAuthPage) {
goto('/login');
}
});
</script>
{#if isAuthPage || auth.loading}
{@render children()}
{:else if auth.user}
<div class="flex h-screen">
<!-- Sidebar -->
<aside class="w-56 border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col">
<div class="p-4 border-b border-[var(--color-border)]">
<h1 class="text-base font-semibold tracking-tight">Immich Watcher</h1>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">Album notifications</p>
</div>
<nav class="flex-1 p-2 space-y-0.5">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors
{page.url.pathname === item.href
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
>
<span class="text-base">{item.icon}</span>
{item.label}
</a>
{/each}
{#if auth.isAdmin}
<a
href="/users"
class="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors
{page.url.pathname === '/users'
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
>
<span class="text-base"></span>
Users
</a>
{/if}
</nav>
<div class="p-3 border-t border-[var(--color-border)]">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
</div>
<button
onclick={logout}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
>
Logout
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-auto">
<div class="max-w-5xl mx-auto p-6">
{@render children()}
</div>
</main>
</div>
{/if}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
let status = $state<any>(null);
onMount(async () => {
try { status = await api('/status'); } catch { /* ignore */ }
});
</script>
<PageHeader title="Dashboard" description="Overview of your Immich Watcher setup" />
{#if status}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">Servers</p>
<p class="text-3xl font-semibold mt-1">{status.servers}</p>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">Active Trackers</p>
<p class="text-3xl font-semibold mt-1">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
</Card>
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">Targets</p>
<p class="text-3xl font-semibold mt-1">{status.targets}</p>
</Card>
</div>
<h3 class="text-lg font-medium mb-3">Recent Events</h3>
{#if status.recent_events.length === 0}
<Card>
<p class="text-sm text-[var(--color-muted-foreground)]">No events yet. Create a tracker to start monitoring albums.</p>
</Card>
{:else}
<Card>
<div class="divide-y divide-[var(--color-border)]">
{#each status.recent_events as event}
<div class="py-3 first:pt-0 last:pb-0">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium">{event.album_name}</span>
<span class="text-xs ml-2 px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{event.event_type}</span>
</div>
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(event.created_at).toLocaleString()}</span>
</div>
</div>
{/each}
</div>
</Card>
{/if}
{:else}
<p class="text-sm text-[var(--color-muted-foreground)]">Loading...</p>
{/if}

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { login } from '$lib/auth.svelte';
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
onMount(async () => {
try {
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
if (res.needs_setup) goto('/setup');
} catch { /* ignore */ }
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
submitting = true;
try {
await login(username, password);
goto('/');
} catch (err: any) {
error = err.message || 'Login failed';
}
submitting = false;
}
</script>
<div class="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
<div class="w-full max-w-sm">
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
<h1 class="text-xl font-semibold text-center mb-1">Immich Watcher</h1>
<p class="text-sm text-[var(--color-muted-foreground)] text-center mb-6">Sign in to your account</p>
{#if error}
<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-1.5">Username</label>
<input
id="username"
type="text"
bind:value={username}
required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1.5">Password</label>
<input
id="password"
type="password"
bind:value={password}
required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]"
/>
</div>
<button
type="submit"
disabled={submitting}
class="w-full py-2 px-4 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
>
{submitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
let servers = $state<any[]>([]);
let showForm = $state(false);
let form = $state({ name: 'Immich', url: '', api_key: '' });
let error = $state('');
let submitting = $state(false);
onMount(load);
async function load() {
try { servers = await api('/servers'); } catch { /* ignore */ }
}
async function create(e: SubmitEvent) {
e.preventDefault();
error = '';
submitting = true;
try {
await api('/servers', { method: 'POST', body: JSON.stringify(form) });
form = { name: 'Immich', url: '', api_key: '' };
showForm = false;
await load();
} catch (err: any) { error = err.message; }
submitting = false;
}
async function remove(id: number) {
if (!confirm('Delete this server?')) return;
await api(`/servers/${id}`, { method: 'DELETE' });
await load();
}
</script>
<PageHeader title="Servers" description="Manage Immich server connections">
<button onclick={() => showForm = !showForm}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? 'Cancel' : 'Add Server'}
</button>
</PageHeader>
{#if showForm}
<Card class="mb-6">
{#if error}
<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>
{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input bind:value={form.name} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Immich URL</label>
<input bind:value={form.url} required placeholder="http://immich:2283" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">API Key</label>
<input bind:value={form.api_key} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{submitting ? 'Connecting...' : 'Add Server'}
</button>
</form>
</Card>
{/if}
{#if servers.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">No servers configured yet.</p></Card>
{:else}
<div class="space-y-3">
{#each servers as server}
<Card>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
</div>
<button onclick={() => remove(server.id)} class="text-xs text-[var(--color-destructive)] hover:underline">Delete</button>
</div>
</Card>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { setup } from '$lib/auth.svelte';
let username = $state('admin');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let submitting = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
submitting = true;
try {
await setup(username, password);
goto('/');
} catch (err: any) {
error = err.message || 'Setup failed';
}
submitting = false;
}
</script>
<div class="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
<div class="w-full max-w-sm">
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
<h1 class="text-xl font-semibold text-center mb-1">Welcome</h1>
<p class="text-sm text-[var(--color-muted-foreground)] text-center mb-6">Create your admin account to get started</p>
{#if error}
<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-1.5">Username</label>
<input
id="username"
type="text"
bind:value={username}
required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1.5">Password</label>
<input
id="password"
type="password"
bind:value={password}
required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]"
/>
</div>
<div>
<label for="confirm" class="block text-sm font-medium mb-1.5">Confirm password</label>
<input
id="confirm"
type="password"
bind:value={confirmPassword}
required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]"
/>
</div>
<button
type="submit"
disabled={submitting}
class="w-full py-2 px-4 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
>
{submitting ? 'Creating account...' : 'Create account'}
</button>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
let targets = $state<any[]>([]);
let showForm = $state(false);
let formType = $state<'telegram' | 'webhook'>('telegram');
let form = $state({ name: '', bot_token: '', chat_id: '', url: '', headers: '' });
let error = $state('');
let testResult = $state('');
onMount(load);
async function load() {
try { targets = await api('/targets'); } catch { /* ignore */ }
}
async function create(e: SubmitEvent) {
e.preventDefault();
error = '';
const config = formType === 'telegram'
? { bot_token: form.bot_token, chat_id: form.chat_id }
: { url: form.url, headers: form.headers ? JSON.parse(form.headers) : {} };
try {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config }) });
showForm = false;
form = { name: '', bot_token: '', chat_id: '', url: '', headers: '' };
await load();
} catch (err: any) { error = err.message; }
}
async function test(id: number) {
testResult = 'Sending...';
try {
const res = await api(`/targets/${id}/test`, { method: 'POST' });
testResult = res.success ? 'Test sent successfully!' : `Failed: ${res.error}`;
} catch (err: any) { testResult = `Error: ${err.message}`; }
setTimeout(() => testResult = '', 5000);
}
async function remove(id: number) {
if (!confirm('Delete this target?')) return;
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
}
</script>
<PageHeader title="Targets" description="Notification destinations (Telegram, webhooks)">
<button onclick={() => { showForm = !showForm; }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? 'Cancel' : 'Add Target'}
</button>
</PageHeader>
{#if testResult}
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes('success') ? 'bg-green-50 text-green-700' : 'bg-yellow-50 text-yellow-700'}">{testResult}</div>
{/if}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={create} class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Type</label>
<div class="flex gap-4">
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="telegram" /> Telegram</label>
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="webhook" /> Webhook</label>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input bind:value={form.name} required placeholder="My notifications" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">Bot Token</label>
<input bind:value={form.bot_token} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Chat ID</label>
<input bind:value={form.chat_id} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{:else}
<div>
<label class="block text-sm font-medium mb-1">Webhook URL</label>
<input bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">Add Target</button>
</form>
</Card>
{/if}
{#if targets.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">No notification targets configured yet.</p></Card>
{:else}
<div class="space-y-3">
{#each targets as target}
<Card>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{target.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{target.type === 'telegram' ? `Chat: ${target.config.chat_id || '***'}` : target.config.url || ''}
</p>
</div>
<div class="flex items-center gap-3">
<button onclick={() => test(target.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Test</button>
<button onclick={() => remove(target.id)} class="text-xs text-[var(--color-destructive)] hover:underline">Delete</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
let templates = $state<any[]>([]);
let showForm = $state(false);
let form = $state({ name: '', body: '{{ added_count }} new item(s) added to album "{{ album_name }}".' });
let preview = $state('');
let editing = $state<number | null>(null);
let error = $state('');
onMount(load);
async function load() {
try { templates = await api('/templates'); } catch { /* ignore */ }
}
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
try {
if (editing) {
await api(`/templates/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
} else {
await api('/templates', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false;
editing = null;
await load();
} catch (err: any) { error = err.message; }
}
async function doPreview(id: number) {
try {
const res = await api<{ rendered: string }>(`/templates/${id}/preview`, { method: 'POST' });
preview = res.rendered;
} catch (err: any) { preview = `Error: ${err.message}`; }
}
function edit(t: any) {
form = { name: t.name, body: t.body };
editing = t.id;
showForm = true;
preview = '';
}
async function remove(id: number) {
if (!confirm('Delete this template?')) return;
await api(`/templates/${id}`, { method: 'DELETE' });
await load();
}
</script>
<PageHeader title="Templates" description="Jinja2 message templates for notifications">
<button onclick={() => { showForm = !showForm; editing = null; form = { name: '', body: '{{ added_count }} new item(s) added to album "{{ album_name }}".' }; preview = ''; }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? 'Cancel' : 'New Template'}
</button>
</PageHeader>
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input bind:value={form.name} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Template Body (Jinja2)</label>
<textarea bind:value={form.body} rows={8}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono"
></textarea>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
Variables: {'{{ album_name }}'}, {'{{ added_count }}'}, {'{{ removed_count }}'}, {'{{ people }}'}, {'{{ change_type }}'}, {'{{ album_url }}'}, {'{{ added_assets }}'}
</p>
</div>
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? 'Update' : 'Create'} Template
</button>
</form>
</Card>
{/if}
{#if templates.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">No templates yet. A default template will be used if none is configured.</p></Card>
{:else}
<div class="space-y-3">
{#each templates as template}
<Card>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-medium">{template.name}</p>
<pre class="text-xs text-[var(--color-muted-foreground)] mt-1 whitespace-pre-wrap font-mono bg-[var(--color-muted)] rounded p-2">{template.body.slice(0, 200)}{template.body.length > 200 ? '...' : ''}</pre>
{#if preview && editing === null}
<!-- show preview inline if triggered -->
{/if}
</div>
<div class="flex items-center gap-3 ml-4">
<button onclick={() => doPreview(template.id)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Preview</button>
<button onclick={() => edit(template)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">Edit</button>
<button onclick={() => remove(template.id)} class="text-xs text-[var(--color-destructive)] hover:underline">Delete</button>
</div>
</div>
</Card>
{/each}
</div>
{#if preview && !showForm}
<Card class="mt-4">
<p class="text-sm font-medium mb-2">Preview</p>
<pre class="text-sm whitespace-pre-wrap bg-[var(--color-muted)] rounded p-3">{preview}</pre>
</Card>
{/if}
{/if}

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
let trackers = $state<any[]>([]);
let servers = $state<any[]>([]);
let targets = $state<any[]>([]);
let albums = $state<any[]>([]);
let showForm = $state(false);
let form = $state({ name: '', server_id: 0, album_ids: [] as string[], event_types: ['assets_added'], target_ids: [] as number[], scan_interval: 60 });
let error = $state('');
onMount(load);
async function load() {
[trackers, servers, targets] = await Promise.all([
api('/trackers'), api('/servers'), api('/targets')
]);
}
async function loadAlbums() {
if (!form.server_id) return;
albums = await api(`/servers/${form.server_id}/albums`);
}
async function create(e: SubmitEvent) {
e.preventDefault();
error = '';
try {
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
showForm = false;
await load();
} catch (err: any) { error = err.message; }
}
async function toggle(tracker: any) {
await api(`/trackers/${tracker.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !tracker.enabled })
});
await load();
}
async function remove(id: number) {
if (!confirm('Delete this tracker?')) return;
await api(`/trackers/${id}`, { method: 'DELETE' });
await load();
}
function toggleAlbum(albumId: string) {
if (form.album_ids.includes(albumId)) {
form.album_ids = form.album_ids.filter(id => id !== albumId);
} else {
form.album_ids = [...form.album_ids, albumId];
}
}
function toggleTarget(targetId: number) {
if (form.target_ids.includes(targetId)) {
form.target_ids = form.target_ids.filter(id => id !== targetId);
} else {
form.target_ids = [...form.target_ids, targetId];
}
}
</script>
<PageHeader title="Trackers" description="Monitor albums for changes">
<button onclick={() => { showForm = !showForm; form = { name: '', server_id: 0, album_ids: [], event_types: ['assets_added'], target_ids: [], scan_interval: 60 }; }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? 'Cancel' : 'New Tracker'}
</button>
</PageHeader>
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={create} class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input bind:value={form.name} required placeholder="Family photos tracker" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Server</label>
<select bind:value={form.server_id} onchange={loadAlbums} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0} disabled>Select server...</option>
{#each servers as s}<option value={s.id}>{s.name}</option>{/each}
</select>
</div>
{#if albums.length > 0}
<div>
<label class="block text-sm font-medium mb-1">Albums</label>
<div class="max-h-48 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
{#each albums as album}
<label class="flex items-center gap-2 text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
</label>
{/each}
</div>
</div>
{/if}
<div>
<label class="block text-sm font-medium mb-1">Event Types</label>
<div class="flex flex-wrap gap-2">
{#each ['assets_added', 'assets_removed', 'album_renamed', 'album_sharing_changed', 'changed'] as evt}
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" checked={form.event_types.includes(evt)}
onchange={() => form.event_types = form.event_types.includes(evt) ? form.event_types.filter(e => e !== evt) : [...form.event_types, evt]} />
{evt}
</label>
{/each}
</div>
</div>
{#if targets.length > 0}
<div>
<label class="block text-sm font-medium mb-1">Notification Targets</label>
<div class="flex flex-wrap gap-2">
{#each targets as t}
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" checked={form.target_ids.includes(t.id)} onchange={() => toggleTarget(t.id)} />
{t.name} ({t.type})
</label>
{/each}
</div>
</div>
{/if}
<div>
<label class="block text-sm font-medium mb-1">Scan Interval (seconds)</label>
<input type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-32 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">Create Tracker</button>
</form>
</Card>
{/if}
{#if trackers.length === 0 && !showForm}
<Card><p class="text-sm text-[var(--color-muted-foreground)]">No trackers yet. Add a server first, then create a tracker.</p></Card>
{:else}
<div class="space-y-3">
{#each trackers as tracker}
<Card>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-green-100 text-green-700' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? 'Active' : 'Paused'}
</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} album(s) · every {tracker.scan_interval}s · {tracker.event_types.join(', ')}</p>
</div>
<div class="flex items-center gap-3">
<button onclick={() => toggle(tracker)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{tracker.enabled ? 'Pause' : 'Resume'}
</button>
<button onclick={() => remove(tracker.id)} class="text-xs text-[var(--color-destructive)] hover:underline">Delete</button>
</div>
</div>
</Card>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { getAuth } from '$lib/auth.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
const auth = getAuth();
let users = $state<any[]>([]);
let showForm = $state(false);
let form = $state({ username: '', password: '', role: 'user' });
let error = $state('');
onMount(load);
async function load() {
try { users = await api('/users'); } catch { /* ignore */ }
}
async function create(e: SubmitEvent) {
e.preventDefault();
error = '';
try {
await api('/users', { method: 'POST', body: JSON.stringify(form) });
form = { username: '', password: '', role: 'user' };
showForm = false;
await load();
} catch (err: any) { error = err.message; }
}
async function remove(id: number) {
if (!confirm('Delete this user?')) return;
try {
await api(`/users/${id}`, { method: 'DELETE' });
await load();
} catch (err: any) { alert(err.message); }
}
</script>
<PageHeader title="Users" description="Manage user accounts (admin only)">
<button onclick={() => showForm = !showForm}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? 'Cancel' : 'Add User'}
</button>
</PageHeader>
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-red-50 text-red-700 text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={create} class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">Username</label>
<input bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Password</label>
<input bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Role</label>
<select bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">Create User</button>
</form>
</Card>
{/if}
<div class="space-y-3">
{#each users as user}
<Card>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · joined {new Date(user.created_at).toLocaleDateString()}</p>
</div>
{#if user.id !== auth.user?.id}
<button onclick={() => remove(user.id)} class="text-xs text-[var(--color-destructive)] hover:underline">Delete</button>
{/if}
</div>
</Card>
{/each}
</div>