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:
@@ -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.
|
- **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`
|
- **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).
|
- 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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { initTheme } from '$lib/theme.svelte.ts';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
initTheme();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center animate-fade-slide-in">
|
||||||
<h1 class="text-4xl font-bold text-foreground mb-2">Notify Bridge</h1>
|
<h1 class="text-5xl font-bold text-foreground mb-3">{t('app.name')}</h1>
|
||||||
<p class="text-muted-foreground">Service-to-notification bridge</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user