Update README.md to include project stack, development and build instructions, and details on PocketBase and shadcn-svelte integration.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export interface EmployeeRecord {
|
||||
id: string;
|
||||
First_Name: string;
|
||||
Last_Name: string;
|
||||
Phone_Number: string;
|
||||
Email_Address: string;
|
||||
Voxer_ID: string;
|
||||
Status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import type { EmployeeRecord } from './+page.server';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
|
||||
let employees = $state<EmployeeRecord[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
|
||||
let openCreate = $state(false);
|
||||
let openEdit = $state(false);
|
||||
let openDelete = $state(false);
|
||||
let editingEmployee = $state<EmployeeRecord | null>(null);
|
||||
let deletingEmployee = $state<EmployeeRecord | null>(null);
|
||||
|
||||
let formFirst = $state('');
|
||||
let formLast = $state('');
|
||||
let formPhone = $state('');
|
||||
let formEmail = $state('');
|
||||
let formVoxer = $state('');
|
||||
let formStatus = $state('');
|
||||
let formSaving = $state(false);
|
||||
let formError = $state<string | null>(null);
|
||||
let deleteSaving = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
async function loadEmployees() {
|
||||
try {
|
||||
const res = await fetch('/api/employees');
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
employees = json.employees ?? [];
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load employees';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formFirst = '';
|
||||
formLast = '';
|
||||
formPhone = '';
|
||||
formEmail = '';
|
||||
formVoxer = '';
|
||||
formStatus = '';
|
||||
formError = null;
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
resetForm();
|
||||
editingEmployee = null;
|
||||
openCreate = true;
|
||||
}
|
||||
|
||||
function openEditDialog(emp: EmployeeRecord) {
|
||||
editingEmployee = emp;
|
||||
formFirst = emp.First_Name ?? '';
|
||||
formLast = emp.Last_Name ?? '';
|
||||
formPhone = emp.Phone_Number ?? '';
|
||||
formEmail = emp.Email_Address ?? '';
|
||||
formVoxer = emp.Voxer_ID ?? '';
|
||||
formStatus = emp.Status ?? '';
|
||||
formError = null;
|
||||
openEdit = true;
|
||||
}
|
||||
|
||||
function openDeleteDialog(emp: EmployeeRecord) {
|
||||
deletingEmployee = emp;
|
||||
deleteError = null;
|
||||
openDelete = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
formSaving = true;
|
||||
formError = null;
|
||||
try {
|
||||
const res = await fetch('/api/employees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
First_Name: formFirst,
|
||||
Last_Name: formLast,
|
||||
Phone_Number: formPhone,
|
||||
Email_Address: formEmail,
|
||||
Voxer_ID: formVoxer,
|
||||
Status: formStatus
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || res.statusText);
|
||||
}
|
||||
openCreate = false;
|
||||
await loadEmployees();
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : 'Failed to create';
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editingEmployee) return;
|
||||
formSaving = true;
|
||||
formError = null;
|
||||
try {
|
||||
const res = await fetch(`/api/employees/${editingEmployee.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
First_Name: formFirst,
|
||||
Last_Name: formLast,
|
||||
Phone_Number: formPhone,
|
||||
Email_Address: formEmail,
|
||||
Voxer_ID: formVoxer,
|
||||
Status: formStatus
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || res.statusText);
|
||||
}
|
||||
openEdit = false;
|
||||
editingEmployee = null;
|
||||
await loadEmployees();
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : 'Failed to update';
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deletingEmployee) return;
|
||||
deleteSaving = true;
|
||||
deleteError = null;
|
||||
try {
|
||||
const res = await fetch(`/api/employees/${deletingEmployee.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
openDelete = false;
|
||||
deletingEmployee = null;
|
||||
await loadEmployees();
|
||||
} catch (e) {
|
||||
deleteError = e instanceof Error ? e.message : 'Failed to delete';
|
||||
} finally {
|
||||
deleteSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEmployees = $derived(
|
||||
!searchQuery.trim()
|
||||
? employees
|
||||
: employees.filter((emp) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
(emp.First_Name ?? '').toLowerCase().includes(q) ||
|
||||
(emp.Last_Name ?? '').toLowerCase().includes(q) ||
|
||||
(emp.Email_Address ?? '').toLowerCase().includes(q) ||
|
||||
(emp.Phone_Number ?? '').toLowerCase().includes(q) ||
|
||||
(emp.Voxer_ID ?? '').toLowerCase().includes(q) ||
|
||||
(emp.Status ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
onMount(loadEmployees);
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col gap-6 p-6 md:p-8">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div class="relative min-w-0 flex-1 sm:max-w-sm">
|
||||
<SearchIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, phone, Voxer ID, status…"
|
||||
bind:value={searchQuery}
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onclick={openCreateDialog} class="shrink-0">
|
||||
<PlusIcon class="size-4" />
|
||||
Add employee
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>First Name</Table.Head>
|
||||
<Table.Head>Last Name</Table.Head>
|
||||
<Table.Head>Phone</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Voxer ID</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head class="w-[70px]">Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if loading}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground py-8 text-center">
|
||||
Loading…
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if error}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-destructive py-8 text-center">
|
||||
{error}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each filteredEmployees as employee (employee.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{employee.First_Name}</Table.Cell>
|
||||
<Table.Cell>{employee.Last_Name}</Table.Cell>
|
||||
<Table.Cell>{employee.Phone_Number}</Table.Cell>
|
||||
<Table.Cell>{employee.Email_Address}</Table.Cell>
|
||||
<Table.Cell>{employee.Voxer_ID}</Table.Cell>
|
||||
<Table.Cell>{employee.Status}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex size-8 items-center justify-center rounded-md hover:bg-accent"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="size-8">
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => openEditDialog(employee)}>
|
||||
<PencilIcon class="size-4" />
|
||||
Edit
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
variant="destructive"
|
||||
onclick={() => openDeleteDialog(employee)}
|
||||
>
|
||||
<Trash2Icon class="size-4" />
|
||||
Delete
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-muted-foreground text-center">
|
||||
{searchQuery.trim()
|
||||
? 'No matching employees.'
|
||||
: 'No employee records found.'}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Create dialog -->
|
||||
<Dialog.Root bind:open={openCreate}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add employee</Dialog.Title>
|
||||
<Dialog.Description>Create a new employee record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCreate();
|
||||
}}
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="create-first">First Name</Label>
|
||||
<Input id="create-first" bind:value={formFirst} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-last">Last Name</Label>
|
||||
<Input id="create-last" bind:value={formLast} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-phone">Phone</Label>
|
||||
<Input id="create-phone" bind:value={formPhone} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-email">Email</Label>
|
||||
<Input id="create-email" type="email" bind:value={formEmail} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-voxer">Voxer ID</Label>
|
||||
<Input id="create-voxer" bind:value={formVoxer} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="create-status">Status</Label>
|
||||
<Input id="create-status" bind:value={formStatus} />
|
||||
</div>
|
||||
{#if formError}
|
||||
<p class="text-destructive text-sm">{formError}</p>
|
||||
{/if}
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
<Button type="submit" disabled={formSaving}>
|
||||
{formSaving ? 'Saving…' : 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<Dialog.Root bind:open={openEdit}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit employee</Dialog.Title>
|
||||
<Dialog.Description>Update this employee record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitEdit();
|
||||
}}
|
||||
class="grid gap-4 py-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-first">First Name</Label>
|
||||
<Input id="edit-first" bind:value={formFirst} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-last">Last Name</Label>
|
||||
<Input id="edit-last" bind:value={formLast} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-phone">Phone</Label>
|
||||
<Input id="edit-phone" bind:value={formPhone} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-email">Email</Label>
|
||||
<Input id="edit-email" type="email" bind:value={formEmail} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-voxer">Voxer ID</Label>
|
||||
<Input id="edit-voxer" bind:value={formVoxer} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit-status">Status</Label>
|
||||
<Input id="edit-status" bind:value={formStatus} />
|
||||
</div>
|
||||
{#if formError}
|
||||
<p class="text-destructive text-sm">{formError}</p>
|
||||
{/if}
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
<Button type="submit" disabled={formSaving}>
|
||||
{formSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<Dialog.Root bind:open={openDelete}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Delete employee</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Are you sure you want to delete
|
||||
{#if deletingEmployee}
|
||||
<strong>{deletingEmployee.First_Name} {deletingEmployee.Last_Name}</strong>?
|
||||
{/if}
|
||||
This cannot be undone.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if deleteError}
|
||||
<p class="text-destructive text-sm">{deleteError}</p>
|
||||
{/if}
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</Dialog.Close>
|
||||
<Button variant="destructive" disabled={deleteSaving} onclick={confirmDelete}>
|
||||
{deleteSaving ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
logout: async ({ locals }) => {
|
||||
locals.pb.authStore.clear();
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card.Root class="w-full max-w-[400px]">
|
||||
<Card.Header class="space-y-1 text-center">
|
||||
<Card.Title class="text-2xl font-semibold">Access denied</Card.Title>
|
||||
<Card.Description>
|
||||
Your account does not have HRM access. Contact an administrator if you believe this is an error.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/logout" use:enhance class="space-y-4">
|
||||
<Button type="submit" variant="outline" class="w-full">
|
||||
Log out
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</main>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export interface EmployeeRecord {
|
||||
id: string;
|
||||
First_Name: string;
|
||||
Last_Name: string;
|
||||
Phone_Number: string;
|
||||
Email_Address: string;
|
||||
Voxer_ID: string;
|
||||
Status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const COLLECTION = 'Employee_Records';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const { items } = await locals.pb
|
||||
.collection(COLLECTION)
|
||||
.getList<EmployeeRecord>(1, 500, { sort: 'Last_Name,First_Name' });
|
||||
|
||||
return json({ employees: items });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json();
|
||||
const record = await locals.pb.collection(COLLECTION).create<EmployeeRecord>({
|
||||
First_Name: body.First_Name ?? '',
|
||||
Last_Name: body.Last_Name ?? '',
|
||||
Phone_Number: body.Phone_Number ?? '',
|
||||
Email_Address: body.Email_Address ?? '',
|
||||
Voxer_ID: body.Voxer_ID ?? '',
|
||||
Status: body.Status ?? ''
|
||||
});
|
||||
return json(record);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import type { EmployeeRecord } from '../+server';
|
||||
|
||||
const COLLECTION = 'Employee_Records';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const id = params.id;
|
||||
const body = await request.json();
|
||||
const record = await locals.pb.collection(COLLECTION).update<EmployeeRecord>(id, {
|
||||
...(body.First_Name !== undefined && { First_Name: body.First_Name }),
|
||||
...(body.Last_Name !== undefined && { Last_Name: body.Last_Name }),
|
||||
...(body.Phone_Number !== undefined && { Phone_Number: body.Phone_Number }),
|
||||
...(body.Email_Address !== undefined && { Email_Address: body.Email_Address }),
|
||||
...(body.Voxer_ID !== undefined && { Voxer_ID: body.Voxer_ID }),
|
||||
...(body.Status !== undefined && { Status: body.Status })
|
||||
});
|
||||
return json(record);
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
await locals.pb.collection(COLLECTION).delete(params.id);
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 10% 3.9%);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 10% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
/** Allow redirectTo only to same-origin paths (no open redirect). */
|
||||
function safeRedirectTo(searchParams: URLSearchParams): string {
|
||||
const to = searchParams.get('redirectTo');
|
||||
if (!to || typeof to !== 'string') return '/';
|
||||
if (!to.startsWith('/') || to.startsWith('//')) return '/';
|
||||
return to;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ url, locals }) => {
|
||||
if (locals.user) {
|
||||
throw redirect(303, safeRedirectTo(url.searchParams));
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, locals, url }) => {
|
||||
const data = await request.formData();
|
||||
const email = (data.get('email') as string)?.trim() ?? '';
|
||||
const password = (data.get('password') as string) ?? '';
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: 'Email and password are required.', email });
|
||||
}
|
||||
|
||||
try {
|
||||
await locals.pb.collection('users').authWithPassword(email, password);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String((err as { message: string }).message)
|
||||
: 'Invalid email or password.';
|
||||
return fail(401, { error: message, email });
|
||||
}
|
||||
|
||||
const redirectTo = safeRedirectTo(url.searchParams);
|
||||
throw redirect(303, redirectTo);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { formAction, data }: { formAction: any; data: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card.Root class="w-full max-w-[400px]">
|
||||
<Card.Header class="space-y-1 text-center">
|
||||
<Card.Title class="text-2xl font-semibold">Login</Card.Title>
|
||||
<Card.Description>
|
||||
Enter your email and password to sign in.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/login"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
value={data?.email ?? ''}
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">Password</Label>
|
||||
<a
|
||||
href="/login/forgot-password"
|
||||
class="text-muted-foreground hover:text-primary text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
{#if data?.error}
|
||||
<p class="text-destructive text-sm" role="alert">{data.error}</p>
|
||||
{/if}
|
||||
<Button type="submit" class="w-full">
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</main>
|
||||
Reference in New Issue
Block a user