Add SvelteKit frontend with Tailwind CSS (Phase 4)
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal 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
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal 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
3560
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal 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
24
frontend/src/app.css
Normal 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
13
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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
87
frontend/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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 |
63
frontend/src/lib/auth.svelte.ts
Normal file
63
frontend/src/lib/auth.svelte.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
10
frontend/src/lib/components/Card.svelte
Normal file
10
frontend/src/lib/components/Card.svelte
Normal 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>
|
||||
19
frontend/src/lib/components/PageHeader.svelte
Normal file
19
frontend/src/lib/components/PageHeader.svelte
Normal 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>
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
90
frontend/src/routes/+layout.svelte
Normal file
90
frontend/src/routes/+layout.svelte
Normal 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}
|
||||
56
frontend/src/routes/+page.svelte
Normal file
56
frontend/src/routes/+page.svelte
Normal 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}
|
||||
74
frontend/src/routes/login/+page.svelte
Normal file
74
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
87
frontend/src/routes/servers/+page.svelte
Normal file
87
frontend/src/routes/servers/+page.svelte
Normal 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}
|
||||
84
frontend/src/routes/setup/+page.svelte
Normal file
84
frontend/src/routes/setup/+page.svelte
Normal 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>
|
||||
120
frontend/src/routes/targets/+page.svelte
Normal file
120
frontend/src/routes/targets/+page.svelte
Normal 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}
|
||||
116
frontend/src/routes/templates/+page.svelte
Normal file
116
frontend/src/routes/templates/+page.svelte
Normal 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}
|
||||
164
frontend/src/routes/trackers/+page.svelte
Normal file
164
frontend/src/routes/trackers/+page.svelte
Normal 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}
|
||||
85
frontend/src/routes/users/+page.svelte
Normal file
85
frontend/src/routes/users/+page.svelte
Normal 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>
|
||||
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal 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
20
frontend/tsconfig.json
Normal 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
12
frontend/vite.config.ts
Normal 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
72
plans/phase-4-frontend.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user