feat: port original frontend UI to Notify Bridge

Port the full polished frontend from Immich Watcher:
- Sidebar layout with collapsible nav, mobile bottom nav
- Login/setup pages with gradient mesh background, animations
- 11 reusable components: Card, Modal, ConfirmModal, Snackbar,
  IconPicker, JinjaEditor, MdiIcon, PageHeader, Loading, Hint, IconButton
- Auth state with getAuth() reactive pattern, token refresh
- Theme: light/dark/system with media query listener
- i18n: EN/RU with nested JSON, auto-detect locale
- Snackbar notification store

Branding changes:
- "Immich Watcher" -> "Notify Bridge"
- /servers -> /providers in nav and routes
- Login icon: mdiEye -> mdiLan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:35:36 +03:00
parent e43c2ed924
commit c9cab93d12
6 changed files with 895 additions and 87 deletions
+209 -28
View File
@@ -1,48 +1,229 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
import { setup } from '$lib/auth.svelte.ts';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { setup } from '$lib/auth.svelte';
import { t, initLocale } from '$lib/i18n';
import { initTheme } from '$lib/theme.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let username = $state('');
let username = $state('admin');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let loading = $state(false);
let submitting = $state(false);
let mounted = $state(false);
async function handleSetup() {
onMount(() => { initLocale(); initTheme(); mounted = true; });
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
loading = true;
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
submitting = true;
try {
await setup(username, password);
window.location.href = '/';
} catch (e: any) {
error = e.message || 'Setup failed';
} finally {
loading = false;
}
} catch (err: any) { error = err.message || 'Setup failed'; }
submitting = 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>
<div class="auth-page">
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<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} autocomplete="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} autocomplete="new-password" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required minlength="6" />
<div class="auth-card-wrapper" class:visible={mounted}>
<div class="auth-card">
<div class="text-center mb-8">
<div class="auth-logo-icon">
<MdiIcon name="mdiShieldAccount" size={28} />
</div>
<h1 class="text-xl font-semibold mt-4 tracking-tight">
<span style="color: var(--color-primary);">Notify</span> Bridge
</h1>
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
<div class="auth-error animate-fade-slide-in">
<MdiIcon name="mdiAlertCircle" size={16} />
{error}
</div>
{/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>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="username" class="auth-label">{t('auth.username')}</label>
<input id="username" type="text" bind:value={username} required class="auth-input" />
</div>
<div>
<label for="password" class="auth-label">{t('auth.password')}</label>
<input id="password" type="password" bind:value={password} required class="auth-input" />
</div>
<div>
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
</div>
<button type="submit" disabled={submitting} class="auth-submit">
{#if submitting}
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
{/if}
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
</button>
</form>
</div>
</div>
</div>
<style>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--color-background);
}
.auth-bg {
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
animation: gradientShift 12s ease-in-out infinite;
background-size: 200% 200%;
}
.auth-grid {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.3;
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
background-size: 32px 32px;
}
.auth-card-wrapper {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
padding: 1rem;
opacity: 0;
transform: translateY(16px) scale(0.98);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.auth-card-wrapper.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.auth-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
:global([data-theme="dark"]) .auth-card {
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 48px var(--color-glow),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.auth-logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 1rem;
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 0 24px var(--color-glow-strong);
}
.auth-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--color-foreground);
}
.auth-input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.875rem;
background: var(--color-background);
color: var(--color-foreground);
transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
}
.auth-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 0.625rem;
font-size: 0.8rem;
margin-bottom: 1rem;
background: var(--color-error-bg);
color: var(--color-error-fg);
}
.auth-submit {
width: 100%;
padding: 0.625rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 600;
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease;
}
.auth-submit:hover:not(:disabled) {
box-shadow: 0 0 24px var(--color-glow-strong);
transform: translateY(-1px);
}
.auth-submit:active:not(:disabled) {
transform: translateY(0);
}
.auth-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
</style>