Files
minstrel/web/src/lib/components/NotificationBell.svelte
T
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00

149 lines
5.3 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { Bell } from 'lucide-svelte';
import {
badgeLabel,
createNotificationsQuery,
createUnreadCountQuery,
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '#lib/api/notifications.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
const client = useQueryClient();
const unreadStore = createUnreadCountQuery();
const listStore = createNotificationsQuery();
const unread = $derived(($unreadStore.data as number | undefined) ?? 0);
const items = $derived((($listStore.data?.items ?? []) as NotificationItem[]));
const badge = $derived(badgeLabel(unread));
let open = $state(false);
let panelRef: HTMLElement | undefined = $state();
let buttonRef: HTMLElement | undefined = $state();
// Same outside-click shape as the user menu in Shell: no stopPropagation on
// the panel, so SvelteKit's link interception keeps working inside it.
function handleWindowClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node | null;
if (!target) return;
if (panelRef?.contains(target) || buttonRef?.contains(target)) return;
open = false;
}
function refresh() {
client.invalidateQueries({ queryKey: ['notifications'] });
}
async function openItem(item: NotificationItem) {
open = false;
if (!item.read_at) {
try {
await markNotificationRead(item.id);
} catch {
// Opening the subject matters more than the read mark; the next
// refresh shows it unread again, which is the honest state.
}
refresh();
}
if (item.link) await goto(item.link);
}
async function markAll() {
try {
await markAllNotificationsRead();
refresh();
} catch {
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
}
}
</script>
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
<div class="relative shrink-0">
<button
bind:this={buttonRef}
type="button"
class="relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
aria-haspopup="dialog"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Bell size={20} strokeWidth={1.75} aria-hidden="true" />
{#if badge}
<span
data-testid="notification-badge"
class="absolute right-1 top-1 min-w-[18px] rounded-full bg-text-primary px-1 text-center text-xs leading-[18px] text-background"
aria-hidden="true"
>
{badge}
</span>
{/if}
</button>
{#if open}
<div
bind:this={panelRef}
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow-sm"
role="dialog"
aria-label="Notifications"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium text-text-primary">Notifications</span>
{#if unread > 0}
<button
type="button"
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
onclick={markAll}
>
Mark all read
</button>
{/if}
</div>
{#if items.length === 0}
<p class="px-3 py-6 text-center text-sm text-text-secondary">Nothing waiting for you.</p>
{:else}
<ul class="overflow-y-auto">
{#each items as item (item.id)}
<li class="border-b border-border last:border-b-0">
<button
type="button"
data-testid="notification-item"
data-unread={item.read_at ? undefined : 'true'}
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
<span
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
>
{item.title}
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
</span>
{#if item.body}
<span class="block text-sm text-text-secondary">{item.body}</span>
{/if}
<span class="block text-xs text-text-muted">{relativeTime(item.created_at)}</span>
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>