Logo VelaStack

Enable notifications

Add notifications to your SvelteKit app. Scaffolds a /notifications page with a list of notifications, a NotificationCard component, and a NotificationList component.

$ vela enable notifications
src/lib/components/notifications-bell.svelte
<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>
src/lib/server/notifications.ts
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,
  });
}
src/routes/(app)/notifications/+page.server.ts
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 };
  },
};
src/routes/(app)/notifications/+page.svelte
<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>