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>
149 lines
5.3 KiB
Svelte
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>
|