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:
@@ -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>
|
||||
Reference in New Issue
Block a user