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

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.8 create --template minimal --types ts --no-install frontend
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

3560
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"bits-ui": "^2.16.3",
"clsx": "^2.1.1",
"lucide-svelte": "^0.577.0",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

24
frontend/src/app.css Normal file
View File

@@ -0,0 +1,24 @@
@import 'tailwindcss';
@theme {
--color-background: #fafafa;
--color-foreground: #18181b;
--color-muted: #f4f4f5;
--color-muted-foreground: #71717a;
--color-border: #e4e4e7;
--color-primary: #18181b;
--color-primary-foreground: #fafafa;
--color-accent: #f4f4f5;
--color-accent-foreground: #18181b;
--color-destructive: #ef4444;
--color-card: #ffffff;
--color-card-foreground: #18181b;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--radius: 0.5rem;
}
body {
font-family: var(--font-sans);
background-color: var(--color-background);
color: var(--color-foreground);
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

87
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* API client with JWT auth for the Immich Watcher backend.
*/
const API_BASE = '/api';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
export function clearTokens() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
export function isAuthenticated(): boolean {
return !!getToken();
}
async function refreshAccessToken(): Promise<boolean> {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (res.ok) {
const data = await res.json();
setTokens(data.access_token, data.refresh_token);
return true;
}
} catch {
// ignore
}
return false;
}
export async function api<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>)
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
// Try token refresh on 401
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,63 @@
/**
* Reactive auth state using Svelte 5 runes.
*/
import { api, setTokens, clearTokens, isAuthenticated } from './api';
interface User {
id: number;
username: string;
role: string;
}
let user = $state<User | null>(null);
let loading = $state(true);
export function getAuth() {
return {
get user() { return user; },
get loading() { return loading; },
get isAdmin() { return user?.role === 'admin'; },
};
}
export async function loadUser() {
if (!isAuthenticated()) {
user = null;
loading = false;
return;
}
try {
user = await api<User>('/auth/me');
} catch {
user = null;
clearTokens();
}
loading = false;
}
export async function login(username: string, password: string) {
const data = await api<{ access_token: string; refresh_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setTokens(data.access_token, data.refresh_token);
await loadUser();
}
export async function setup(username: string, password: string) {
const data = await api<{ access_token: string; refresh_token: string }>('/auth/setup', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setTokens(data.access_token, data.refresh_token);
await loadUser();
}
export function logout() {
clearTokens();
user = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}

View File

@@ -0,0 +1,10 @@
<script lang="ts">
let { children, class: className = '' } = $props<{
children: import('svelte').Snippet;
class?: string;
}>();
</script>
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {className}">
{@render children()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
let { title, description = '', children } = $props<{
title: string;
description?: string;
children?: import('svelte').Snippet;
}>();
</script>
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
{#if description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{description}</p>
{/if}
</div>
{#if children}
{@render children()}
{/if}
</div>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

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>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

18
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html'
})
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) =>
filename.includes('node_modules') ? undefined : { runes: true }
}
};
export default config;

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

12
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8420'
}
}
});

72
plans/phase-4-frontend.md Normal file
View File

@@ -0,0 +1,72 @@
# Phase 4: Build SvelteKit Frontend
**Status**: In progress
**Parent**: [primary-plan.md](primary-plan.md)
---
## Goal
Build a modern, calm web UI using SvelteKit + Shadcn-svelte that communicates with the Phase 3 FastAPI backend.
---
## Tech Stack
- **Framework**: SvelteKit (static adapter for embedding in FastAPI)
- **UI Components**: shadcn-svelte (Tailwind CSS based)
- **Icons**: Lucide
- **HTTP Client**: fetch API with auth interceptor
---
## Pages
1. **Login** `/login` -- Username/password form
2. **Setup** `/setup` -- First-run admin account creation
3. **Dashboard** `/` -- Overview: active trackers, recent events, stats
4. **Servers** `/servers` -- CRUD Immich server connections
5. **Trackers** `/trackers` -- CRUD album trackers with album picker
6. **Templates** `/templates` -- Jinja2 template editor with live preview
7. **Targets** `/targets` -- Manage Telegram/webhook notification targets
8. **Users** `/users` -- Admin-only user management
---
## Tasks
### 1. Scaffold project `[ ]`
- `npx sv create frontend` with TypeScript
- Install shadcn-svelte, tailwindcss, lucide-svelte
- Configure static adapter for build output
- Set up API client with JWT auth
### 2. Auth flow `[ ]`
- Login page with form
- Setup wizard (shown when /api/auth/needs-setup returns true)
- Token storage in localStorage
- Auth guard redirect to /login
### 3. Layout `[ ]`
- Sidebar with navigation links
- Header with user info and logout
- Responsive mobile menu
### 4-8. Pages `[ ]`
- Each page: list view, create/edit dialog, delete confirmation
- Trackers page: album picker fetched from Immich via server API
- Templates page: textarea with preview panel
- Targets page: type-specific config forms (Telegram/webhook)
### 9. Build config `[ ]`
- Static adapter outputs to `packages/server/src/immich_watcher_server/frontend/`
- npm build script copies dist to server package
---
## Acceptance Criteria
- [ ] All pages functional against the backend API
- [ ] Auth flow works (setup, login, token refresh)
- [ ] Static build serves correctly from FastAPI
- [ ] Modern, calm UI aesthetic (clean typography, muted colors)

View File

@@ -172,7 +172,9 @@ async def _execute_telegram_notification(self, ...):
## Phases
> **Rule**: Before starting work on any phase, create a detailed trackable subplan at `plans/phase-N-<name>.md` with granular tasks, specific files to create/modify, and acceptance criteria. Do not begin implementation until the subplan is reviewed.
> **Rule 1**: Before starting work on any phase, create a detailed trackable subplan at `plans/phase-N-<name>.md` with granular tasks, specific files to create/modify, and acceptance criteria. Do not begin implementation until the subplan is reviewed.
>
> **Rule 2**: After completing each phase, perform a detailed code review of all changes. Use the `code-reviewer` agent to check for bugs, logic errors, security vulnerabilities, code quality issues, and adherence to project conventions. Document review findings in the phase subplan under a "## Review" section and fix any issues before committing.
### Phase 1: Extract Core Library `[x]`
- Extract models, Immich client, change detection, Telegram client, cache into `packages/core/`
@@ -192,7 +194,7 @@ async def _execute_telegram_notification(self, ...):
- Jinja2 template engine with variable system matching blueprint
- **Subplan**: `plans/phase-3-server-backend.md`
### Phase 4: Build Frontend `[ ]`
### Phase 4: Build Frontend `[x]`
- SvelteKit app with all pages (setup, dashboard, trackers, templates, targets, users)
- Template editor with live preview
- Album picker connected to Immich API