Add notifications to your SvelteKit app. Scaffolds a /notifications page with a list of notifications, a NotificationCard component, and a NotificationList component.
<script lang="ts">
import { onMount } from "svelte";
import { goto, invalidate } from "$app/navigation";
import { page } from "$app/state";
import { toast } from "svelte-sonner";
import BellIcon from "@lucide/svelte/icons/bell";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { timeAgo } from "$lib/utils";
interface NotificationItem {
id: string;
title: string;
body: string;
read: boolean;
created: string;
}
const POLL_MS = 30_000;
const INVALIDATE_KEY = "app:notifications";
const notifications = $derived(
(page.data.notifications ?? { count: 0, items: [] }) as {
count: number;
items: NotificationItem[];
},
);
const badgeText = $derived(
notifications.count > 99 ? "99+" : String(notifications.count),
);
let lastSeenId: string | null = null;
let primed = false;
$effect(() => {
const newest = notifications.items[0]?.id ?? null;
if (!primed) {
primed = true;
lastSeenId = newest;
return;
}
if (!newest || newest === lastSeenId) return;
for (const item of notifications.items) {
if (lastSeenId && item.id <= lastSeenId) break;
toast.info(item.title, {
description: item.body || undefined,
action: { label: "View", onClick: () => goto("/notifications") },
});
}
lastSeenId = newest;
});
onMount(() => {
const id = setInterval(() => {
if (document.visibilityState === "hidden") return;
invalidate(INVALIDATE_KEY);
}, POLL_MS);
const onVisible = () => {
if (document.visibilityState === "visible") invalidate(INVALIDATE_KEY);
};
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
});
</script>
<DropdownMenu.Root onOpenChange={(open) => open && invalidate(INVALIDATE_KEY)}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
variant="ghost"
size="icon"
class="relative"
aria-label="Notifications"
{...props}
>
<BellIcon class="size-5" />
{#if notifications.count > 0}
<Badge
variant="destructive"
class="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] leading-none"
>
{badgeText}
</Badge>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-80 shadow-2xl shadow-black/40">
<DropdownMenu.Label>Notifications</DropdownMenu.Label>
<DropdownMenu.Separator />
{#if notifications.items.length === 0}
<div class="px-2 py-6 text-center text-sm text-muted-foreground">
You're all caught up.
</div>
{:else}
{#each notifications.items as item (item.id)}
<DropdownMenu.Item class="p-0">
<a
href="/notifications"
class="flex w-full flex-col gap-0.5 px-2 py-2 cursor-default"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate">{item.title}</span>
<span
class="shrink-0 text-[10px] text-muted-foreground tabular-nums"
title={new Date(item.created).toLocaleString()}
>
{timeAgo(item.created)}
</span>
</div>
{#if item.body}
<span class="text-xs text-muted-foreground line-clamp-2"
>{item.body}</span
>
{/if}
</a>
</DropdownMenu.Item>
{/each}
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item class="p-0">
<a
href="/notifications"
class="flex w-full justify-center px-2 py-1.5 text-sm cursor-default"
>
View all
</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
import type PocketBase from "pocketbase-sveltekit";
export interface NotifyInput {
title: string;
body?: string;
}
/** Drop a notification on a user. Server-only ($lib/server). */
export async function notify(
pb: PocketBase,
userId: string,
input: NotifyInput,
) {
return pb.collection("notifications").create({
user: userId,
title: input.title,
body: input.body ?? "",
read: false,
});
}
import { fail } from "@sveltejs/kit";
export const load = async ({ locals }) => {
const userId = locals.pb.authStore.record!.id;
const items = await locals.pb.collection("notifications").getFullList({
filter: `user = "${userId}"`,
sort: "-created",
requestKey: "notifications",
});
const unread = items.filter((n) => !n.read).length;
const breadcrumbs = [
{ title: "Home", url: "/dashboard" },
{ title: "Notifications", url: "/notifications" },
];
return { items, unread, breadcrumbs };
};
export const actions = {
markRead: async ({ request, locals }) => {
const form = await request.formData();
const id = form.get("id");
if (typeof id !== "string" || !id)
return fail(400, { error: "missing id" });
await locals.pb.collection("notifications").update(id, { read: true });
return { success: true };
},
markAllRead: async ({ locals }) => {
const userId = locals.pb.authStore.record!.id;
const unread = await locals.pb.collection("notifications").getFullList({
filter: `user = "${userId}" && read = false`,
});
for (const n of unread) {
await locals.pb.collection("notifications").update(n.id, { read: true });
}
return { success: true, count: unread.length };
},
};
<script lang="ts">
import { enhance } from "$app/forms";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import * as Card from "$lib/components/ui/card";
import { timeAgo } from "$lib/utils";
let { data } = $props();
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold">Notifications</h1>
{#if data.unread > 0}
<Badge variant="secondary">{data.unread} unread</Badge>
{/if}
</div>
{#if data.unread > 0}
<form method="post" action="?/markAllRead" use:enhance>
<Button type="submit" variant="outline" size="sm">Mark all read</Button>
</form>
{/if}
</div>
{#if data.items.length === 0}
<Card.Root>
<Card.Content class="py-10 text-center text-sm text-muted-foreground">
You don't have any notifications yet.
</Card.Content>
</Card.Root>
{:else}
<div class="flex flex-col gap-2">
{#each data.items as item (item.id)}
<Card.Root
size="sm"
class={["relative transition-colors", item.read ? "opacity-70" : ""]}
>
<Card.Content class="flex items-start justify-between gap-4">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="font-medium">{item.title}</span>
{#if !item.read}
<span
class="size-2 rounded-full bg-primary"
aria-label="unread"
></span>
{/if}
</div>
{#if item.body}
<p class="text-sm text-muted-foreground">{item.body}</p>
{/if}
{#if item.created}
<span
class="text-xs text-muted-foreground"
title={new Date(item.created).toLocaleString()}
>
{timeAgo(item.created)}
</span>
{/if}
</div>
{#if !item.read}
<form method="post" action="?/markRead" use:enhance>
<input type="hidden" name="id" value={item.id} />
<Button type="submit" variant="outline" size="sm"
>Mark read</Button
>
</form>
{/if}
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
const TIME_AGO_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
export function timeAgo(date: Date | string | number, locale?: string) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const diff = (new Date(date).getTime() - Date.now()) / 1000;
for (const [unit, secs] of TIME_AGO_UNITS) {
if (Math.abs(diff) >= secs || unit === "second") {
return rtf.format(Math.round(diff / secs), unit);
}
}
}
notifications