feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit f935754df4
120 changed files with 21772 additions and 90 deletions

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { z } from 'zod';
// Props
interface Props {
isLoading?: boolean;
errorMessage?: string | null;
}
let { isLoading = false, errorMessage = null }: Props = $props();
// Event dispatcher
const dispatch = createEventDispatcher<{
login: { email: string; password: string };
}>();
// Validation schema
const loginSchema = z.object({
email: z.string().min(1, 'Email is required').email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
type LoginFormData = z.infer<typeof loginSchema>;
// Form data - use $state for reactivity
let formData: LoginFormData = $state({
email: '',
password: '',
});
// Validation errors - use $state for reactivity
let errors: Partial<Record<keyof LoginFormData, string>> = $state({});
// Validate form
function validateForm(): boolean {
errors = {};
try {
loginSchema.parse(formData);
return true;
} catch (err) {
if (err instanceof z.ZodError) {
err.errors.forEach((error) => {
const field = error.path[0] as keyof LoginFormData;
errors[field] = error.message;
});
}
return false;
}
}
// Handle form submission
function handleSubmit(event: Event) {
event.preventDefault();
if (validateForm()) {
dispatch('login', formData);
}
}
</script>
<form class="space-y-4" onsubmit={handleSubmit}>
{#if errorMessage}
<div class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{errorMessage}</span>
</div>
{/if}
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email</span>
</label>
<input
type="email"
id="email"
class="input input-bordered w-full"
class:input-error={errors.email}
placeholder="admin@example.com"
bind:value={formData.email}
disabled={isLoading}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{#if errors.email}
<span id="email-error" class="label-text-alt text-error" data-testid="email-error">{errors.email}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="password">
<span class="label-text">Password</span>
</label>
<input
type="password"
id="password"
class="input input-bordered w-full"
class:input-error={errors.password}
placeholder="••••••••"
bind:value={formData.password}
disabled={isLoading}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{#if errors.password}
<span id="password-error" class="label-text-alt text-error" data-testid="password-error">{errors.password}</span>
{/if}
</div>
<button
type="submit"
class="btn btn-primary w-full"
disabled={isLoading}
>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
Logging in...
{:else}
Login
{/if}
</button>
</form>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { user, logout } from '$lib/stores/auth';
import { goto } from '$app/navigation';
// Get user from store using $derived for reactivity
let currentUser = $derived($user);
async function handleLogout() {
await logout();
goto('/login');
}
</script>
<nav class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a href="/" class="btn btn-ghost normal-case text-xl">Headroom</a>
</div>
<div class="flex-none gap-2">
{#if currentUser}
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full bg-primary">
<span class="text-xl">{currentUser.email?.charAt(0).toUpperCase()}</span>
</div>
</label>
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">
<li>
<a href="/dashboard" class="justify-between">
Dashboard
</a>
</li>
{#if currentUser.role === 'superuser' || currentUser.role === 'manager'}
<li><a href="/team-members">Team Members</a></li>
<li><a href="/projects">Projects</a></li>
{/if}
<li><a href="/reports">Reports</a></li>
<div class="divider"></div>
<li>
<button onclick={handleLogout} class="text-error">Logout</button>
</li>
</ul>
</div>
{:else}
<a href="/login" class="btn btn-primary btn-sm">Login</a>
{/if}
</div>
</nav>