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:
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center animate-fade-slide-in">
|
||||||
|
<h1 class="text-6xl font-bold text-muted-foreground mb-4">{page.status}</h1>
|
||||||
|
<p class="text-lg text-muted-foreground mb-8">{page.error?.message || 'Page not found'}</p>
|
||||||
|
<a href="/" class="px-6 py-3 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity">
|
||||||
|
Go home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,33 +1,294 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { initTheme } from '$lib/theme.svelte.ts';
|
import { page } from '$app/state';
|
||||||
import { checkAuth, isLoading, isAuthenticated, getNeedsSetup } from '$lib/auth.svelte.ts';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||||
|
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||||
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
import Snackbar from '$lib/components/Snackbar.svelte';
|
||||||
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
let mounted = $state(false);
|
const auth = getAuth();
|
||||||
|
const theme = getTheme();
|
||||||
|
|
||||||
onMount(() => {
|
let showPasswordForm = $state(false);
|
||||||
|
let pwdCurrent = $state('');
|
||||||
|
let pwdNew = $state('');
|
||||||
|
let pwdMsg = $state('');
|
||||||
|
let pwdSuccess = $state(false);
|
||||||
|
|
||||||
|
async function changePassword(e: SubmitEvent) {
|
||||||
|
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||||
|
try {
|
||||||
|
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||||
|
pwdMsg = t('common.passwordChanged');
|
||||||
|
pwdSuccess = true;
|
||||||
|
pwdCurrent = ''; pwdNew = '';
|
||||||
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
|
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
|
||||||
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = $state(false);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||||
|
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||||
|
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||||
|
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||||
|
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||||
|
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||||
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isAuthPage = $derived(
|
||||||
|
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
initLocale();
|
||||||
initTheme();
|
initTheme();
|
||||||
checkAuth().then(() => {
|
if (typeof localStorage !== 'undefined') {
|
||||||
mounted = true;
|
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
const path = window.location.pathname;
|
}
|
||||||
const publicPaths = ['/login', '/setup'];
|
await loadUser();
|
||||||
|
if (!auth.user && !isAuthPage) {
|
||||||
console.log('[auth]', { path, needsSetup: getNeedsSetup(), authenticated: isAuthenticated(), token: !!localStorage.getItem('nb_token') });
|
goto('/login');
|
||||||
|
}
|
||||||
if (getNeedsSetup() && path !== '/setup') {
|
|
||||||
window.location.href = '/setup';
|
|
||||||
} else if (!getNeedsSetup() && !isAuthenticated() && !publicPaths.includes(path)) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const order: Theme[] = ['light', 'dark', 'system'];
|
||||||
|
const idx = order.indexOf(theme.current);
|
||||||
|
setTheme(order[(idx + 1) % order.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLocale() {
|
||||||
|
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('sidebar_collapsed', String(collapsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
return page.url.pathname === href;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !mounted || isLoading()}
|
{#if isAuthPage}
|
||||||
|
{@render children()}
|
||||||
|
{:else if auth.loading}
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
<p class="text-muted-foreground">Loading...</p>
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if auth.user}
|
||||||
|
<div class="flex h-screen">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
|
||||||
|
style="background: var(--color-sidebar); border-right: 1px solid var(--color-border); transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
|
{#if !collapsed}
|
||||||
|
<div class="animate-fade-slide-in">
|
||||||
|
<h1 class="text-base font-semibold tracking-tight" style="color: var(--color-foreground);">
|
||||||
|
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||||
|
</h1>
|
||||||
|
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button onclick={toggleSidebar}
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground); background: transparent;"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
|
||||||
|
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||||
|
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||||
|
{#each navItems as item}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
|
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(item.href) ? '500' : '400'};"
|
||||||
|
onmouseenter={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
|
onmouseleave={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
|
title={collapsed ? t(item.key) : ''}
|
||||||
|
>
|
||||||
|
{#if isActive(item.href)}
|
||||||
|
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name={item.icon} size={18} />
|
||||||
|
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{#if auth.isAdmin}
|
||||||
|
<a
|
||||||
|
href="/users"
|
||||||
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
|
style="color: {isActive('/users') ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive('/users') ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive('/users') ? '500' : '400'};"
|
||||||
|
onmouseenter={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
|
onmouseleave={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
|
title={collapsed ? t('nav.users') : ''}
|
||||||
|
>
|
||||||
|
{#if isActive('/users')}
|
||||||
|
<div style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||||
|
{#if !collapsed}<span class="truncate">{t('nav.users')}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="border-top: 1px solid var(--color-border);">
|
||||||
|
<!-- Theme & Language -->
|
||||||
|
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
|
||||||
|
<button onclick={toggleLocale}
|
||||||
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
|
||||||
|
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
|
title={t('common.language')}>
|
||||||
|
{getLocale().toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<button onclick={cycleTheme}
|
||||||
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
|
||||||
|
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
|
title={t('common.theme')}>
|
||||||
|
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User info -->
|
||||||
|
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
|
||||||
|
{#if collapsed}
|
||||||
|
<button onclick={logout}
|
||||||
|
class="w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
title={t('nav.logout')}>
|
||||||
|
<MdiIcon name="mdiLogout" size={16} />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="px-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold"
|
||||||
|
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||||
|
{auth.user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{auth.user.username}</p>
|
||||||
|
<p class="text-[0.65rem] tracking-wide uppercase" style="color: var(--color-muted-foreground);">{auth.user.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick={logout}
|
||||||
|
class="p-1.5 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
title={t('nav.logout')}>
|
||||||
|
<MdiIcon name="mdiLogout" size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => showPasswordForm = true}
|
||||||
|
class="text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"
|
||||||
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-primary)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}>
|
||||||
|
<MdiIcon name="mdiKeyVariant" size={12} />
|
||||||
|
{t('common.changePassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile bottom nav -->
|
||||||
|
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||||
|
{#each navItems.slice(0, 5) as item}
|
||||||
|
<a href={item.href}
|
||||||
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||||
|
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||||
|
<MdiIcon name={item.icon} size={20} />
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<button onclick={logout}
|
||||||
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||||
|
<MdiIcon name="mdiLogout" size={20} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||||
|
{#key page.url.pathname}
|
||||||
|
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@render children()}
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||||
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password change modal -->
|
||||||
|
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
|
||||||
|
<form onsubmit={changePassword} class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||||
|
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||||
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
|
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
||||||
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
{#if pwdMsg}
|
||||||
|
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||||
|
style="background: var(--color-primary); color: var(--color-primary-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Snackbar />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-nav { display: flex !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { t } from '$lib/i18n';
|
||||||
|
import { getAuth } from '$lib/auth.svelte';
|
||||||
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
|
|
||||||
|
const auth = getAuth();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||||
<div class="text-center animate-fade-slide-in">
|
|
||||||
<h1 class="text-5xl font-bold text-foreground mb-3">{t('app.name')}</h1>
|
<div class="text-center py-12">
|
||||||
<p class="text-lg text-muted-foreground mb-8">{t('app.tagline')}</p>
|
<p class="text-muted-foreground">{t('dashboard.noEvents')}</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>
|
||||||
|
|||||||
@@ -1,47 +1,272 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { goto } from '$app/navigation';
|
||||||
import { login } from '$lib/auth.svelte.ts';
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { login } from '$lib/auth.svelte';
|
||||||
|
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
|
||||||
|
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
|
const theme = getTheme();
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loading = $state(false);
|
let submitting = $state(false);
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
async function handleLogin() {
|
onMount(async () => {
|
||||||
|
initLocale();
|
||||||
|
initTheme();
|
||||||
|
mounted = true;
|
||||||
|
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 = '';
|
error = '';
|
||||||
loading = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (e: any) {
|
} catch (err: any) {
|
||||||
error = e.message || 'Login failed';
|
error = err.message || 'Login failed';
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
|
submitting = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="auth-page">
|
||||||
<div class="w-full max-w-md p-8 bg-card rounded-2xl border border-border shadow-lg animate-fade-slide-in">
|
<!-- Animated gradient mesh background -->
|
||||||
<h1 class="text-2xl font-bold text-center mb-6">{t('app.name')}</h1>
|
<div class="auth-bg"></div>
|
||||||
|
<div class="auth-grid"></div>
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
|
<!-- Login card -->
|
||||||
<div>
|
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.username')}</label>
|
<div class="auth-card">
|
||||||
<input type="text" bind:value={username} autocomplete="username" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
<!-- Controls -->
|
||||||
|
<div class="flex justify-end gap-1.5 mb-6">
|
||||||
|
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||||
|
class="auth-control-btn">
|
||||||
|
{getLocale().toUpperCase()}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
||||||
|
class="auth-control-btn">
|
||||||
|
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.password')}</label>
|
<!-- Logo / title -->
|
||||||
<input type="password" bind:value={password} autocomplete="current-password" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
<div class="text-center mb-8">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<MdiIcon name="mdiLan" 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.signInTitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#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}
|
{/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">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
{loading ? t('common.loading') : t('auth.login')}
|
<div>
|
||||||
</button>
|
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||||
</form>
|
<input id="username" type="text" bind:value={username} required
|
||||||
|
class="auth-input" placeholder="admin" />
|
||||||
|
</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>
|
||||||
|
<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.signingIn') : t('auth.signIn')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</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;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
: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-control-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-control-btn:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: 0 0 8px var(--color-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n/index.svelte.ts';
|
||||||
|
import { api } from '$lib/api.ts';
|
||||||
|
|
||||||
|
let providerType = $state('immich');
|
||||||
|
let name = $state('');
|
||||||
|
let url = $state('');
|
||||||
|
let apiKey = $state('');
|
||||||
|
let externalDomain = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let testing = $state(false);
|
||||||
|
let testResult = $state<{ ok: boolean; message: string } | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
if (!url || !apiKey) {
|
||||||
|
error = 'URL and API Key are required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testing = true;
|
||||||
|
testResult = null;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
// Save first to get an ID, then test
|
||||||
|
const provider = await api.post<any>('/providers', {
|
||||||
|
type: providerType,
|
||||||
|
name: name || 'Immich',
|
||||||
|
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
|
||||||
|
});
|
||||||
|
testResult = await api.post<{ ok: boolean; message: string }>(`/providers/${provider.id}/test`);
|
||||||
|
if (!testResult.ok) {
|
||||||
|
// Clean up failed provider
|
||||||
|
await api.delete(`/providers/${provider.id}`);
|
||||||
|
} else {
|
||||||
|
// Success — redirect to providers list
|
||||||
|
window.location.href = '/providers';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || 'Test failed';
|
||||||
|
} finally {
|
||||||
|
testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!url || !apiKey) {
|
||||||
|
error = 'URL and API Key are required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api.post('/providers', {
|
||||||
|
type: providerType,
|
||||||
|
name: name || 'Immich',
|
||||||
|
config: { url, api_key: apiKey, external_domain: externalDomain || undefined },
|
||||||
|
});
|
||||||
|
window.location.href = '/providers';
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || 'Save failed';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-6 max-w-2xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/providers" class="text-sm text-muted-foreground hover:text-foreground">← Back to Providers</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-6">{t('provider.addProvider')}</h1>
|
||||||
|
|
||||||
|
<div class="bg-card rounded-xl border border-border p-6 space-y-5">
|
||||||
|
<!-- Provider Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-1">Provider Type</label>
|
||||||
|
<select bind:value={providerType} class="w-full px-3 py-2 border border-border rounded-[var(--radius)] bg-background">
|
||||||
|
<option value="immich">{t('provider.immich')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-1">Name</label>
|
||||||
|
<input type="text" bind:value={name} placeholder="My Immich Server" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if providerType === 'immich'}
|
||||||
|
<!-- Immich URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-1">Server URL <span class="text-destructive">*</span></label>
|
||||||
|
<input type="url" bind:value={url} placeholder="http://192.168.1.100:2283" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key <span class="text-destructive">*</span></label>
|
||||||
|
<input type="password" bind:value={apiKey} placeholder="Your Immich API key" autocomplete="off" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External Domain -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-1">External Domain <span class="text-muted-foreground font-normal">(optional)</span></label>
|
||||||
|
<input type="url" bind:value={externalDomain} placeholder="https://photos.example.com" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" />
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">Public-facing URL for notification links. Falls back to server URL.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if testResult}
|
||||||
|
<div class="p-3 rounded-lg {testResult.ok ? 'bg-success-bg text-success-fg' : 'bg-error-bg text-error-fg'}">
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button onclick={testConnection} disabled={testing || saving} class="px-5 py-2.5 bg-primary text-primary-foreground rounded-[var(--radius)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
|
||||||
|
{testing ? 'Testing...' : 'Test & Save'}
|
||||||
|
</button>
|
||||||
|
<button onclick={handleSave} disabled={testing || saving} class="px-5 py-2.5 bg-muted text-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors disabled:opacity-50">
|
||||||
|
{saving ? 'Saving...' : 'Save without testing'}
|
||||||
|
</button>
|
||||||
|
<a href="/providers" class="px-5 py-2.5 bg-muted text-muted-foreground rounded-[var(--radius)] font-medium hover:bg-accent transition-colors">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,48 +1,229 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/index.svelte.ts';
|
import { goto } from '$app/navigation';
|
||||||
import { setup } from '$lib/auth.svelte.ts';
|
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 password = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
let error = $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 = '';
|
error = '';
|
||||||
loading = true;
|
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
||||||
|
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
|
||||||
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
await setup(username, password);
|
await setup(username, password);
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (e: any) {
|
} catch (err: any) { error = err.message || 'Setup failed'; }
|
||||||
error = e.message || 'Setup failed';
|
submitting = false;
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="auth-page">
|
||||||
<div class="w-full max-w-md p-8 bg-card rounded-2xl border border-border shadow-lg animate-fade-slide-in">
|
<div class="auth-bg"></div>
|
||||||
<h1 class="text-2xl font-bold text-center mb-2">{t('app.name')}</h1>
|
<div class="auth-grid"></div>
|
||||||
<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 class="auth-card-wrapper" class:visible={mounted}>
|
||||||
<div>
|
<div class="auth-card">
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.username')}</label>
|
<div class="text-center mb-8">
|
||||||
<input type="text" bind:value={username} autocomplete="username" class="w-full px-3 py-2 border border-border rounded-[var(--radius)]" required />
|
<div class="auth-logo-icon">
|
||||||
</div>
|
<MdiIcon name="mdiShieldAccount" size={28} />
|
||||||
<div>
|
</div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">{t('auth.password')}</label>
|
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#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}
|
{/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">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
{loading ? t('common.loading') : t('auth.setup')}
|
<div>
|
||||||
</button>
|
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||||
</form>
|
<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>
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user