Enhance routing and type definitions for new API endpoints. Added support for notifications and SSO routes, updated layout parameters, and improved type safety in route metadata. Refactored component imports for user and profile pages.
This commit is contained in:
@@ -30,6 +30,12 @@
|
||||
<Button class="h-16 w-full min-h-16" variant="outline">{site.title} | {site.description}</Button>
|
||||
</a>
|
||||
{/each}
|
||||
<a href="/notifications" class="block">
|
||||
<Button class="h-12 w-full" variant="outline">Notifications</Button>
|
||||
</a>
|
||||
<a href="/profile" class="block">
|
||||
<Button class="h-12 w-full" variant="outline">Profile</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
</Button>
|
||||
</a>
|
||||
{/each}
|
||||
<a href="/notifications" class="block">
|
||||
<Button class="h-12 w-full" variant="outline">Notifications</Button>
|
||||
</a>
|
||||
<a href="/profile" class="block">
|
||||
<Button class="h-12 w-full" variant="outline">Profile</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
data: Record<string, unknown> | null;
|
||||
isRead: boolean;
|
||||
isDeleted: boolean;
|
||||
created: string;
|
||||
};
|
||||
|
||||
function readBool(r: Record<string, unknown>, ...keys: string[]): boolean {
|
||||
for (const k of keys) {
|
||||
const v = r[k];
|
||||
if (v === true || v === 'true') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const list = await locals.pb.collection('notifications').getFullList({
|
||||
filter: `user = "${locals.user.id}"`,
|
||||
sort: '-created'
|
||||
});
|
||||
const notifications: NotificationItem[] = list
|
||||
.filter((r: Record<string, unknown>) => !readBool(r, 'isDeleted'))
|
||||
.map((r: Record<string, unknown>) => ({
|
||||
id: r.id as string,
|
||||
type: (r.type as string) ?? '',
|
||||
title: (r.title as string) ?? '',
|
||||
message: (r.message as string) ?? '',
|
||||
data: (r.data as Record<string, unknown>) ?? null,
|
||||
isRead: readBool(r, 'isRead', 'read'),
|
||||
isDeleted: readBool(r, 'isDeleted'),
|
||||
created: (r.created as string) ?? ''
|
||||
}));
|
||||
return json({ notifications });
|
||||
} catch {
|
||||
return json({ notifications: [] });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
let body: { title?: string; message?: string; type?: string } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const title = typeof body.title === 'string' ? body.title : 'Test notification';
|
||||
const message = typeof body.message === 'string' ? body.message : 'This is a test notification.';
|
||||
const type = typeof body.type === 'string' ? body.type : 'system';
|
||||
try {
|
||||
const record = await locals.pb.collection('notifications').create({
|
||||
user: locals.user.id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
data: {},
|
||||
isRead: false,
|
||||
isDeleted: false
|
||||
});
|
||||
const r = record as Record<string, unknown>;
|
||||
return json({
|
||||
notification: {
|
||||
id: r.id,
|
||||
type: r.type ?? 'system',
|
||||
title: r.title ?? title,
|
||||
message: r.message ?? message,
|
||||
data: r.data ?? null,
|
||||
isRead: readBool(r, 'isRead', 'read'),
|
||||
isDeleted: readBool(r, 'isDeleted'),
|
||||
created: r.created ?? new Date().toISOString()
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return json({ error: 'Failed to create notification' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const id = params.id;
|
||||
if (!id) {
|
||||
return json({ error: 'Missing notification id' }, { status: 400 });
|
||||
}
|
||||
let body: { isRead?: boolean; isDeleted?: boolean } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
try {
|
||||
const record = await locals.pb.collection('notifications').getOne(id);
|
||||
if ((record as Record<string, unknown>).user !== locals.user.id) {
|
||||
return json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const updates: Record<string, boolean> = {};
|
||||
if (typeof body.isRead === 'boolean') updates.isRead = body.isRead;
|
||||
if (typeof body.isDeleted === 'boolean') updates.isDeleted = body.isDeleted;
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return json({ error: 'No updates' }, { status: 400 });
|
||||
}
|
||||
await locals.pb.collection('notifications').update(id, updates);
|
||||
return json({ ok: true, ...updates });
|
||||
} catch {
|
||||
return json({ error: 'Notification not found or update failed' }, { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, depends }) => {
|
||||
depends('app:notifications');
|
||||
const res = await fetch('/api/notifications');
|
||||
const data = res.ok ? await res.json() : { notifications: [] };
|
||||
return { notifications: data.notifications ?? [] };
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
data: Record<string, unknown> | null;
|
||||
isRead: boolean;
|
||||
isDeleted: boolean;
|
||||
created: string;
|
||||
};
|
||||
|
||||
let { data }: { data: { notifications: NotificationItem[] } } = $props();
|
||||
let sending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let updatingId = $state<string | null>(null);
|
||||
let deletingId = $state<string | null>(null);
|
||||
|
||||
const unread = $derived(data.notifications.filter((n) => !n.isRead));
|
||||
const read = $derived(data.notifications.filter((n) => n.isRead));
|
||||
|
||||
async function sendTestNotification() {
|
||||
sending = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch('/api/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Test notification',
|
||||
message: 'This is a test notification sent to yourself.',
|
||||
type: 'system'
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Failed to send');
|
||||
}
|
||||
await invalidate('app:notifications');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to send notification';
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(created: string) {
|
||||
if (!created) return '';
|
||||
const d = new Date(created);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
if (!type) return '';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
async function toggleRead(notification: NotificationItem, checked: boolean) {
|
||||
updatingId = notification.id;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/notifications/${notification.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isRead: checked })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Failed to update');
|
||||
}
|
||||
await invalidate('app:notifications');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update';
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotification(notification: NotificationItem) {
|
||||
deletingId = notification.id;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/notifications/${notification.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isDeleted: true })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Failed to delete');
|
||||
}
|
||||
await invalidate('app:notifications');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete';
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Notifications — Base</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-6 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-semibold">Notifications</h1>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={sending}
|
||||
onclick={sendTestNotification}
|
||||
>
|
||||
{sending ? 'Sending…' : 'Test notification'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="text-destructive text-sm">{error}</p>
|
||||
{/if}
|
||||
{#if data.notifications.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No notifications yet.</p>
|
||||
{:else}
|
||||
{#if unread.length > 0}
|
||||
<section>
|
||||
<h2 class="text-muted-foreground mb-2 text-sm font-medium">Unread</h2>
|
||||
<ul class="flex flex-col gap-3">
|
||||
{#each unread as notification (notification.id)}
|
||||
<li>
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Card.Title class="text-base">{notification.title}</Card.Title>
|
||||
{#if notification.type}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs font-medium">
|
||||
{typeLabel(notification.type)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Description class="text-xs">
|
||||
{formatDate(notification.created)}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground h-auto px-2 py-1 text-xs font-normal hover:text-foreground"
|
||||
disabled={updatingId === notification.id}
|
||||
onclick={() => toggleRead(notification, !notification.isRead)}
|
||||
>
|
||||
{updatingId === notification.id ? '…' : notification.isRead ? 'Unread' : 'Read'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
disabled={deletingId === notification.id}
|
||||
onclick={() => deleteNotification(notification)}
|
||||
title="Delete"
|
||||
>
|
||||
{#if deletingId === notification.id}
|
||||
<span class="text-xs">…</span>
|
||||
{:else}
|
||||
<Trash2 class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<p class="text-muted-foreground text-sm">{notification.message}</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if read.length > 0}
|
||||
<section>
|
||||
<h2 class="text-muted-foreground mb-2 text-sm font-medium">Read</h2>
|
||||
<ul class="flex flex-col gap-3">
|
||||
{#each read as notification (notification.id)}
|
||||
<li>
|
||||
<Card.Root class="opacity-75">
|
||||
<Card.Header class="pb-2">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Card.Title class="text-base">{notification.title}</Card.Title>
|
||||
{#if notification.type}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs font-medium">
|
||||
{typeLabel(notification.type)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Description class="text-xs">
|
||||
{formatDate(notification.created)}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground h-auto px-2 py-1 text-xs font-normal hover:text-foreground"
|
||||
disabled={updatingId === notification.id}
|
||||
onclick={() => toggleRead(notification, !notification.isRead)}
|
||||
>
|
||||
{updatingId === notification.id ? '…' : notification.isRead ? 'Unread' : 'Read'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
disabled={deletingId === notification.id}
|
||||
onclick={() => deleteNotification(notification)}
|
||||
title="Delete"
|
||||
>
|
||||
{#if deletingId === notification.id}
|
||||
<span class="text-xs">…</span>
|
||||
{:else}
|
||||
<Trash2 class="size-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="pt-0">
|
||||
<p class="text-muted-foreground text-sm">{notification.message}</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let { data }: { data: App.PageData } = $props();
|
||||
const user = data.user;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile — Base</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-6 sm:p-6">
|
||||
<a href="/" class="self-start">
|
||||
<Button variant="ghost" size="sm">← Base</Button>
|
||||
</a>
|
||||
{#if user}
|
||||
<Card.Root class="max-w-md">
|
||||
<Card.Header class="flex flex-row items-center gap-3">
|
||||
<Avatar.Root class="size-14 rounded-full">
|
||||
<Avatar.Image src={user.avatar as string | undefined} alt="" />
|
||||
<Avatar.Fallback class="bg-muted text-muted-foreground flex size-14 items-center justify-center rounded-full text-lg font-medium">
|
||||
{user.name
|
||||
? String(user.name).slice(0, 2).toUpperCase()
|
||||
: user.email
|
||||
? String(user.email).slice(0, 2).toUpperCase()
|
||||
: '?'}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Card.Title>{user.name ?? user.email ?? 'Profile'}</Card.Title>
|
||||
{#if user.email}
|
||||
<Card.Description>{user.email}</Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-2">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<span class="font-medium text-foreground">Access:</span><br />
|
||||
Prism {user.prism ? '✓' : '—'} · TasGrid {user.tasgrid ? '✓' : '—'} · Stackq {user.stackq ? '✓' : '—'} · HRM {user.hrm ? '✓' : '—'}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<span class="font-medium text-foreground">Dark mode:</span> {user.darkmode ? 'On' : 'Off'}
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">Not signed in.</p>
|
||||
{/if}
|
||||
</main>
|
||||
Reference in New Issue
Block a user