Add user notification inbox and delivery controls

This commit is contained in:
JB
2026-07-25 15:29:21 -04:00
parent ff7f3a6d34
commit 29f4de807a
24 changed files with 606 additions and 18 deletions
+30 -4
View File
@@ -4,9 +4,10 @@
import { onMount, tick } from 'svelte';
import type { Snippet } from 'svelte';
let { user, isacAccess, children }: {
let { user, isacAccess, notificationSummary, children }: {
user: { username: string; displayName: string; role: string } | null;
isacAccess: boolean;
notificationSummary: { unread: number };
children: Snippet;
} = $props();
@@ -19,9 +20,32 @@
let showCookieNotice = $state(false);
let mobileOpen = $state(false);
let mobilePanel = $state<HTMLElement | null>(null);
let unreadNotifications = $state(0);
$effect(() => { unreadNotifications = notificationSummary.unread; });
onMount(() => {
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
if (!user) return;
const poll = async () => {
try {
const response = await fetch('/api/notifications/unread');
if (!response.ok) return;
const result = await response.json();
unreadNotifications = Number(result.count ?? 0);
if (!('Notification' in window) || Notification.permission !== 'granted') return;
const shown = new Set<string>(JSON.parse(localStorage.getItem('shd-browser-notifications') ?? '[]'));
for (const item of result.notifications as Array<{ id: string; title: string; body: string; link?: string }>) {
if (shown.has(item.id)) continue;
const alert = new Notification(item.title, { body: item.body, icon: '/favicon.svg', tag: `shd-${item.id}` });
alert.onclick = () => { window.focus(); location.href = item.link || '/account/notifications'; alert.close(); };
shown.add(item.id);
}
localStorage.setItem('shd-browser-notifications', JSON.stringify([...shown].slice(-100)));
} catch { /* A transient poll failure must not interrupt navigation. */ }
};
void poll();
const interval = window.setInterval(poll, 30_000);
return () => window.clearInterval(interval);
});
function dismissCookieNotice() {
@@ -63,7 +87,7 @@
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
{/if}
{#if isacAccess}
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a>
@@ -71,6 +95,7 @@
</nav>
{#if user}
<div class="account">
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
</div>
@@ -90,11 +115,12 @@
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
{/each}
{#if user}
<a class:active={page.url.pathname.startsWith('/account')} href="/account"><small>04</small><span>My Profile</span><b>›</b></a>
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>04</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
<a class:active={page.url.pathname === '/account'} href="/account"><small>05</small><span>My Profile</span><b>›</b></a>
{:else}
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>05</small><span>ISAC</span><b>›</b></a>{/if}
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
{#if user}
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
type BreadcrumbItem = {
label: string;
href?: string;
};
let { items }: { items: BreadcrumbItem[] } = $props();
</script>
<nav class="eyebrow page-breadcrumb" aria-label="Breadcrumb">
<span class="breadcrumb-line" aria-hidden="true"></span>
<ol>
{#each items as item, index}
<li>
{#if item.href}<a href={item.href}>{item.label}</a>{:else}<span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>{/if}
</li>
{/each}
</ol>
</nav>
@@ -0,0 +1,24 @@
<script lang="ts">
let { body, expanded = false }: { body: string; expanded?: boolean } = $props();
const parsed = $derived.by(() => {
const detailMatch = body.match(/^(.*?)(?:\s+)(Oversight reason|Reason|Required action|Details):\s*(.+)$/i);
if (!detailMatch) return { summary: body, detailLabel: null, detail: null };
return {
summary: detailMatch[1].trim(),
detailLabel: detailMatch[2].toUpperCase(),
detail: detailMatch[3].trim()
};
});
</script>
<div class="system-message-body" class:expanded>
<div class="system-message-kicker"><span aria-hidden="true">◇</span><b>ISAC TRANSMISSION</b></div>
<p>{parsed.summary}</p>
{#if parsed.detail}
<div class="system-message-detail">
<span>{parsed.detailLabel}</span>
<p>{parsed.detail}</p>
</div>
{/if}
</div>