feat(docker-watcher): phase 14 - frontend polish & modern UI
Design system with CSS custom properties (light/dark themes). 38 Lucide SVG icon components. Dark mode with system preference. EN/RU localization with i18n store. Skeleton loaders, empty states, toggle switches, micro-interactions. Responsive sidebar with mobile hamburger menu. All pages polished with consistent styling.
This commit is contained in:
@@ -2,21 +2,26 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { IconLoader } from '$lib/components/icons';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Apply theme on login page too.
|
||||
$effect(() => {
|
||||
applyTheme($resolvedTheme);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Check if we got a token from OIDC callback redirect.
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
localStorage.setItem('auth_token', urlToken);
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// If already logged in, redirect to dashboard.
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
goto('/');
|
||||
@@ -26,25 +31,21 @@
|
||||
async function handleLogin() {
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const envelope = await res.json();
|
||||
|
||||
if (!envelope.success) {
|
||||
error = envelope.error ?? 'Login failed';
|
||||
error = envelope.error ?? $t('login.loginFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', envelope.data.token);
|
||||
goto('/');
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
error = err instanceof Error ? err.message : $t('login.networkError');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -55,72 +56,80 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--surface-page)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
|
||||
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)]">
|
||||
<!-- Logo -->
|
||||
<div class="mb-6 text-center">
|
||||
<svg class="mx-auto h-10 w-10 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
<h1 class="mt-3 text-xl font-bold text-gray-900">Docker Watcher</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Sign in to your account</p>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-600)] shadow-md">
|
||||
<svg class="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mt-4 text-xl font-bold text-[var(--text-primary)]">{$t('login.title')}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
|
||||
<div class="mb-4 rounded-lg bg-[var(--color-danger-light)] p-3 text-sm text-[var(--color-danger)]">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="username" class="text-sm font-medium text-[var(--text-primary)]">{$t('login.username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
required
|
||||
autocomplete="username"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="password" class="text-sm font-medium text-[var(--text-primary)]">{$t('login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
class="w-full inline-flex items-center justify-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:ring-offset-2 disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{#if loading}
|
||||
<IconLoader size={16} />
|
||||
{$t('login.signingIn')}
|
||||
{:else}
|
||||
{$t('login.signIn')}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mt-5">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
<div class="w-full border-t border-[var(--border-primary)]"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs">
|
||||
<span class="bg-white px-2 text-gray-400">or</span>
|
||||
<span class="bg-[var(--surface-card)] px-3 text-[var(--text-tertiary)]">{$t('login.or')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleOIDCLogin}
|
||||
class="mt-4 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
class="mt-4 w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] shadow-sm transition-all duration-150 hover:bg-[var(--surface-card-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:ring-offset-2"
|
||||
>
|
||||
Sign in with SSO (OIDC)
|
||||
{$t('login.ssoButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user