feat(notify-bridge): phase 7 - frontend restructuring

Notify Bridge frontend with SvelteKit 2 + Svelte 5 + Tailwind CSS v4:
- Auth: login page, setup page, auth state management with $state runes
- Theme: dark/light toggle with localStorage persistence
- i18n: EN/RU translations with reactive $state-based t() function
- Routes: dashboard, providers, trackers, targets, tracking-configs,
  template-configs, telegram-bots, users (stubs for configs pages)
- Providers page: list with card grid, "Add Provider" button
- API client: JWT auth, auto-redirect on 401, typed request helpers
- Branding: "Notify Bridge" throughout, Observatory theme colors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:53:37 +03:00
parent 7f99c895a4
commit 9dfd1b79cd
12 changed files with 274 additions and 3 deletions
+15
View File
@@ -37,3 +37,18 @@ PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | he
- **frontend**: SvelteKit 2 + Svelte 5 + Tailwind CSS v4. Static adapter with SPA fallback. Dev proxy to :8420.
- **Environment vars**: `NOTIFY_BRIDGE_DATA_DIR`, `NOTIFY_BRIDGE_SECRET_KEY`, `NOTIFY_BRIDGE_DATABASE_URL`
- Core package includes `jinja2` dependency (template rendering lives in core, not server).
## Entity Relationships (Phase 6)
```
ServiceProvider → type: "immich", config: JSON (url, api_key, external_domain)
Tracker → provider_id, tracking_config_id, target_ids: JSON list, collection_ids: JSON list
TrackingConfig → provider_type (must match provider), event flags, scheduling
TemplateConfig → provider_type (must match provider), Jinja2 slots per event type
NotificationTarget → template_config_id, type: "telegram"/"webhook", config: JSON
```
- TrackingConfig owned by Tracker (what to watch), TemplateConfig owned by Target (how to format)
- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup)
- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup
- API: All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix
+3
View File
@@ -1,7 +1,10 @@
<script>
import '../app.css';
import { initTheme } from '$lib/theme.svelte.ts';
let { children } = $props();
initTheme();
</script>
{@render children()}
+12 -3
View File
@@ -1,9 +1,18 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-bold text-foreground mb-2">Notify Bridge</h1>
<p class="text-muted-foreground">Service-to-notification bridge</p>
<div class="text-center animate-fade-slide-in">
<h1 class="text-5xl font-bold text-foreground mb-3">{t('app.name')}</h1>
<p class="text-lg text-muted-foreground mb-8">{t('app.tagline')}</p>
<div class="flex gap-4 justify-center">
<a href="/login" class="px-6 py-3 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
{t('auth.login')}
</a>
<a href="/providers" class="px-6 py-3 bg-muted text-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors">
{t('nav.providers')}
</a>
</div>
</div>
</div>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { login } from '$lib/auth.svelte.ts';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin() {
error = '';
loading = true;
try {
await login(username, password);
window.location.href = '/';
} catch (e: any) {
error = e.message || 'Login failed';
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md p-8 bg-card rounded-2xl border border-border shadow-lg animate-fade-slide-in">
<h1 class="text-2xl font-bold text-center mb-6">{t('app.name')}</h1>
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.username')}</label>
<input type="text" bind:value={username} class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.password')}</label>
<input type="password" bind:value={password} class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<button type="submit" disabled={loading} class="w-full py-2.5 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{loading ? t('common.loading') : t('auth.login')}
</button>
</form>
</div>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
let providers = $state<any[]>([]);
let loading = $state(true);
onMount(async () => {
try {
providers = await api.get('/providers');
} catch { /* auth redirect handled by api */ }
loading = false;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.providers')}</h1>
<a href="/providers/new" class="px-4 py-2 bg-primary text-primary-foreground rounded-[var(--radius)] text-sm font-medium">
{t('provider.addProvider')}
</a>
</div>
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if providers.length === 0}
<div class="text-center py-16 text-muted-foreground">
<p class="text-lg mb-2">{t('common.noData')}</p>
<p class="text-sm">Add your first service provider to get started.</p>
</div>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 stagger-children">
{#each providers as provider}
<div class="p-4 bg-card rounded-xl border border-border">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary font-bold">
{provider.type[0].toUpperCase()}
</div>
<div>
<h3 class="font-semibold">{provider.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{provider.type}</p>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { setup } from '$lib/auth.svelte.ts';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleSetup() {
error = '';
loading = true;
try {
await setup(username, password);
window.location.href = '/';
} catch (e: any) {
error = e.message || 'Setup failed';
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-md p-8 bg-card rounded-2xl border border-border shadow-lg animate-fade-slide-in">
<h1 class="text-2xl font-bold text-center mb-2">{t('app.name')}</h1>
<p class="text-center text-muted-foreground mb-6">Create your admin account</p>
<form onsubmit={(e) => { e.preventDefault(); handleSetup(); }} class="space-y-4">
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.username')}</label>
<input type="text" bind:value={username} class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
</div>
<div>
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.password')}</label>
<input type="password" bind:value={password} class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required minlength="6" />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<button type="submit" disabled={loading} class="w-full py-2.5 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
{loading ? t('common.loading') : t('auth.setup')}
</button>
</form>
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
let targets = $state<any[]>([]);
let loading = $state(true);
onMount(async () => {
try { targets = await api.get('/targets'); } catch {}
loading = false;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.targets')}</h1>
</div>
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if targets.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each targets as target}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{target.name}</h3>
<p class="text-xs text-muted-foreground capitalize">{target.type}</p>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,8 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.telegramBots')}</h1>
<p class="text-muted-foreground">Telegram bot management — coming soon.</p>
</div>
@@ -0,0 +1,8 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.templateConfigs')}</h1>
<p class="text-muted-foreground">Template configuration management — coming soon.</p>
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { api } from '$lib/api.ts';
import { onMount } from 'svelte';
let trackers = $state<any[]>([]);
let loading = $state(true);
onMount(async () => {
try { trackers = await api.get('/trackers'); } catch {}
loading = false;
});
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{t('nav.trackers')}</h1>
</div>
{#if loading}
<p class="text-muted-foreground">{t('common.loading')}</p>
{:else if trackers.length === 0}
<p class="text-muted-foreground text-center py-16">{t('common.noData')}</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 stagger-children">
{#each trackers as tracker}
<div class="p-4 bg-card rounded-xl border border-border">
<h3 class="font-semibold">{tracker.name}</h3>
<p class="text-xs text-muted-foreground">{tracker.collection_ids?.length || 0} collections</p>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,8 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.trackingConfigs')}</h1>
<p class="text-muted-foreground">Tracking configuration management — coming soon.</p>
</div>
+8
View File
@@ -0,0 +1,8 @@
<script>
import { t } from '$lib/i18n/index.svelte.ts';
</script>
<div class="p-6 max-w-5xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{t('nav.users')}</h1>
<p class="text-muted-foreground">User management — coming soon.</p>
</div>