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
+10 -3
View File
@@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods, notifications, notificationUnread] = await Promise.all([
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
@@ -24,7 +24,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
getAuthenticationMethods(database, user.id)
getAuthenticationMethods(database, user.id),
(await database.prepare(`SELECT n.id,n.category,n.source,n.title,n.body,n.link,n.mandatory,n.created_at,n.read_at
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)
ORDER BY n.created_at DESC LIMIT 5`)).all(user.id),
(await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id)
]);
const oauthErrorCode = url.searchParams.get('oauth_error');
const oauthErrors: Record<string, { title: string; message: string }> = {
@@ -48,7 +55,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}
: null;
return {
account: { builds, guides, favorites, ratings },
account: { builds, guides, favorites, ratings, notifications, notificationUnread: Number(notificationUnread?.count ?? 0) },
discordConnection: connection?.discord_id ? {
id: String(connection.discord_id),
username: connection.discord_username ? String(connection.discord_username) : null,
+37 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
@@ -24,6 +25,19 @@
function initialAvatarPanel(): AvatarPanel {
return form?.avatarError || form?.avatarReviewQueued ? 'upload' : 'presets';
}
const notificationIsIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const notificationTone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const notificationSeverity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'ERROR';
if (/(warning|violation|needs revision)/.test(text)) return 'WARNING';
return null;
};
let accountPanel = $state<AccountPanel>(initialAccountPanel());
let accessPanel = $state<AccessPanel>(initialAccessPanel());
let avatarPanel = $state<AvatarPanel>(initialAvatarPanel());
@@ -65,8 +79,8 @@
<svelte:head><title>{user.displayName} — SHD.build</title></svelte:head>
<main class="page profile-page">
<section class="profile-command">
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><p class="eyebrow"><span></span> AGENT PROFILE</p><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div></div>
<div class="profile-ident"><img class="profile-avatar" src={user.avatarUrl} alt={`${user.displayName}'s avatar`} /><div><PageBreadcrumb items={[{ label: 'AGENT PROFILE' }]} /><h1>{user.displayName}</h1><span class="role-badge">{user.role.toUpperCase()} ACCESS</span></div></div>
<div class="profile-metrics"><div><strong>{data.account.builds.length}</strong><span>BUILDS</span></div><div><strong>{data.account.guides.length}</strong><span>GUIDES</span></div><div><strong>{data.account.favorites.length}</strong><span>FAVORITES</span></div><div><strong>{data.account.ratings.length}</strong><span>RATED</span></div><a href="/account/notifications"><strong>{data.account.notificationUnread}</strong><span>INBOX</span></a></div>
</section>
{#if data.chooseDisplayName}<div class="account-alert"><b>IDENTITY SETUP</b><span>Choose the SHD.build display name other agents will see. Unicode characters are supported.</span></div>{/if}
{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}
@@ -74,6 +88,14 @@
{#if data.oauthError}<div class="account-alert error-alert" role="alert"><b>{data.oauthError.title}</b><span>{data.oauthError.message}</span></div>{/if}
<div class="profile-dashboard">
<section class="profile-feed">
<div class="profile-module profile-notifications">
<div class="module-head"><div><p class="eyebrow">SHD COMMUNICATION NETWORK</p><h2>NOTIFICATIONS</h2></div><a class="secondary" href="/account/notifications">OPEN INBOX{data.account.notificationUnread ? ` · ${data.account.notificationUnread} UNREAD` : ''} →</a></div>
<div class="profile-notification-list">
{#each data.account.notifications as notification}
<a class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={notificationIsIsac(notification)} class:positive={notificationTone(notification) === 'positive'} class:negative={notificationTone(notification) === 'negative'} href="/account/notifications"><div><img src={notificationIsIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp'} alt=""/><span><b>{notification.source}</b><small>{String(notification.category).toUpperCase()} · {new Date(String(notification.created_at)).toLocaleDateString()}</small></span></div><span><strong>{notification.title}</strong><small>{notification.body}</small></span><em>{notificationSeverity(notification) ?? 'OPEN'} ›</em></a>
{:else}<div class="profile-empty compact"><span>◇</span><b>NO NOTIFICATIONS</b><p>ISAC and community messages will appear here.</p></div>{/each}
</div>
</div>
<div class="profile-module"><div class="module-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>MY BUILDS</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>
{#if data.account.builds.length}<div class="profile-table"><div class="profile-row heading"><span>LOADOUT</span><span>VISIBILITY</span><span>UPDATED</span><span></span></div>{#each data.account.builds as build}<div class="profile-row"><div><strong>{build.title}</strong><small>/{build.slug}</small></div><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><button>OPEN →</button></div>{/each}</div>{:else}<div class="profile-empty"><span>◇</span><b>NO BUILDS RECORDED</b><p>Your saved and published loadouts will appear here.</p><a href="/builder">CREATE YOUR FIRST BUILD →</a></div>{/if}
</div>
@@ -89,7 +111,7 @@
{#if accountPanel === 'identity'}
<div class="account-panel" id="identity-panel" role="tabpanel" aria-labelledby="identity-tab">
<p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div id="avatar" class="profile-anchor" aria-hidden="true"></div><p class="eyebrow">AGENT PORTRAIT</p><h2>AVATAR</h2>
<div class="account-subtabs" role="tablist" aria-label="Avatar source">
<button class:active={avatarPanel === 'presets'} role="tab" aria-selected={avatarPanel === 'presets'} onclick={() => avatarPanel = 'presets'}>SHD PRESETS</button>
<button class:active={avatarPanel === 'upload'} role="tab" aria-selected={avatarPanel === 'upload'} onclick={() => avatarPanel = 'upload'}>CUSTOM UPLOAD</button>
@@ -190,3 +212,15 @@
</aside>
</div>
</main>
<style>
.profile-notification-list a > div { grid-template-columns: 30px minmax(0, 1fr); align-items: center; }
.profile-notification-list a > div > img { width: 30px; height: 30px; object-fit: cover; border: 1px solid #3b4446; border-radius: 50%; background: #111; }
.profile-notification-list a > div > span { min-width: 0; display: grid; gap: 3px; }
.profile-notification-list a.isac-message b { color: #8ecce5; }
.profile-notification-list a.positive { border-left: 2px solid var(--shd-success); background: #101713; }
.profile-notification-list a.negative { border-left: 2px solid var(--shd-danger); background: #191111; }
.profile-notification-list a.isac-message:not(.positive):not(.negative) { border-left: 2px solid #68a9c4; background: #10171a; }
.profile-notification-list a.positive em { color: var(--shd-success); }
.profile-notification-list a.negative em { color: var(--shd-danger); }
</style>
@@ -0,0 +1,74 @@
import { fail, redirect } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const database = await getDatabase();
const category = NOTIFICATION_CATEGORIES.includes(url.searchParams.get('category') as any) ? String(url.searchParams.get('category')) : '';
const unreadOnly = url.searchParams.get('state') === 'unread';
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
const args: unknown[] = [user.id];
if (category) { conditions.push('n.category=?'); args.push(category); }
if (unreadOnly) conditions.push('n.read_at IS NULL');
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
${where} ORDER BY n.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
const stored = preferenceRows.find((row) => row.category === name);
return {
category: name,
inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled),
browser: Boolean(stored?.browser_enabled),
emailFrequency: String(stored?.email_frequency ?? 'none')
};
});
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
return { notifications, preferences, pagination, filters: { category, unreadOnly }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
};
export const actions: Actions = {
markRead: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const id = String((await request.formData()).get('id') ?? '');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?')).run(new Date().toISOString(), id, user.id);
return { notificationUpdated: true };
},
markAllRead: async ({ cookies }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL')).run(new Date().toISOString(), user.id);
return { notificationsCleared: true };
},
savePreferences: async ({ cookies, request }) => {
const user = await getSessionUser(cookies);
if (!user) redirect(303, '/sign-in');
const form = await request.formData();
const database = await getDatabase();
const now = new Date().toISOString();
const hasEmail = Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id));
for (const category of NOTIFICATION_CATEGORIES) {
const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on';
const browser = form.get(`browser:${category}`) === 'on';
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.' });
if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.' });
await (await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
}
return { notificationPreferencesSaved: true };
}
};
@@ -0,0 +1,121 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let selected = $state<any>(null);
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)));
const isIsac = (notification: any) => notification.category === 'isac' || String(notification.source).startsWith('ISAC');
const tone = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(approved|accepted|deployed|restored|successful)/.test(text)) return 'positive';
if (/(removed|rejected|not approved|failed|could not|needs revision|violation)/.test(text)) return 'negative';
return 'neutral';
};
const severity = (notification: any) => {
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (/(error|failed|could not)/.test(text)) return 'error';
if (/(warning|violation|needs revision)/.test(text)) return 'warning';
return null;
};
const avatar = (notification: any) => isIsac(notification) ? '/favicon.svg' : '/avatars/default/comms-specialist.webp';
const action = (notification: any) => {
if (!notification.link) return null;
const text = `${notification.title} ${notification.body}`.toLocaleLowerCase();
if (text.includes('avatar') && /(removed|rejected|not approved)/.test(text)) return { label: 'UPLOAD NEW AVATAR', href: '/account#avatar' };
if (notification.category === 'comments' && text.includes('guide')) return { label: 'VIEW GUIDE & REPLY', href: notification.link };
if (notification.category === 'comments') return { label: 'VIEW BUILD & REPLY', href: notification.link };
if (notification.category === 'contributions' && /(needs revision|not approved|could not)/.test(text)) return { label: 'REVISE CONTRIBUTION', href: notification.link };
if (notification.category === 'contributions' && /(approved|deployed)/.test(text)) return { label: 'VIEW PUBLISHED DATA', href: notification.link };
if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link };
if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link };
if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
return { label: 'OPEN RELATED PAGE', href: notification.link };
};
async function enableBrowser() {
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
browserPermission = await Notification.requestPermission();
}
</script>
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
<main class="page profile-page notification-page">
<section class="notification-command" aria-labelledby="notification-page-title">
<div>
<PageBreadcrumb items={[{ label: 'AGENT PROFILE', href: '/account' }, { label: 'NOTIFICATIONS' }]} />
<h1 id="notification-page-title">INBOX</h1>
<p>ISAC directives, contribution decisions, and community activity.</p>
</div>
<div class="notification-command-actions">
<div class="notification-unread-summary"><strong>{data.unread}</strong><span>UNREAD</span></div>
<button class="secondary" type="button" aria-expanded={settingsOpen} onclick={() => settingsOpen = !settingsOpen}>PREFERENCES <span aria-hidden="true">⚙</span></button>
</div>
</section>
{#if settingsOpen}
<section class="notification-settings">
<div class="module-head"><div><p class="eyebrow">DELIVERY CONTROL</p><h2>NOTIFICATION PREFERENCES</h2></div><span>BROWSER: {browserPermission.toUpperCase()}</span></div>
<p class="notification-rule"><b>ISAC INBOX DELIVERY IS MANDATORY.</b> Browser and email delivery remain optional. Email is disabled by default.</p>
{#if browserPermission !== 'granted'}<button class="secondary browser-permission" type="button" onclick={enableBrowser}>ENABLE BROWSER PERMISSION →</button>{/if}
<PendingForm action="?/savePreferences" class="notification-preference-form">
<div class="notification-preference-head"><span>CHANNEL</span><span>INBOX</span><span>BROWSER</span><span>EMAIL</span></div>
{#each data.preferences as preference}
<div class="notification-preference-row"><div><b>{labels[preference.category]}</b><small>{preference.category === 'isac' ? 'MANDATORY OPERATIONS MESSAGES' : preference.category === 'contributions' ? 'ISAC DECISIONS REMAIN REQUIRED' : 'OPTIONAL ACTIVITY CHANNEL'}</small></div><label><input type="checkbox" name={`inApp:${preference.category}`} checked={preference.inApp} disabled={preference.category === 'isac'}/><span>{preference.category === 'isac' ? 'REQUIRED' : 'ENABLED'}</span></label><label><input type="checkbox" name={`browser:${preference.category}`} checked={preference.browser} disabled={browserPermission !== 'granted'}/><span>ALERT</span></label><select name={`email:${preference.category}`} disabled={!data.hasVerifiedEmail}><option value="none" selected={preference.emailFrequency === 'none'}>OFF</option><option value="immediate" selected={preference.emailFrequency === 'immediate'}>IMMEDIATE</option><option value="daily" selected={preference.emailFrequency === 'daily'}>DAILY DIGEST</option></select></div>
{/each}
{#if !data.hasVerifiedEmail}<p class="notification-rule">Add a verified magic-link email from Agent Profile → Access to enable email delivery.</p>{/if}
{#if form?.notificationPreferenceError}<p class="error">{form.notificationPreferenceError}</p>{/if}
{#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if}
<div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div>
</PendingForm>
</section>
{/if}
<div class="notification-toolbar"><form><select name="category"><option value="">ALL CHANNELS</option>{#each Object.entries(labels) as [value,label]}<option {value} selected={data.filters.category === value}>{label}</option>{/each}</select><select name="state"><option value="">ALL MESSAGES</option><option value="unread" selected={data.filters.unreadOnly}>UNREAD ONLY</option></select><button class="secondary">APPLY FILTER →</button></form>{#if data.unread}<PendingForm action="?/markAllRead"><button class="secondary">MARK ALL READ</button></PendingForm>{/if}</div>
<RegistryPagination pagination={data.pagination}/>
<section class="notification-list">
{#each data.notifications as notification}
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
</article>
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
</section>
</main>
{#if selected}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal notification-detail-modal" class:isac-message={isIsac(selected)} class:positive={tone(selected) === 'positive'} class:negative={tone(selected) === 'negative'} role="dialog" aria-modal="true" aria-labelledby="notification-detail-title">
<div class="notification-detail-sender"><img src={avatar(selected)} alt=""/><div><span>{isIsac(selected) ? 'ISAC SYSTEM MESSAGE' : 'COMMUNITY MESSAGE'}</span><b>{selected.source}</b></div><button aria-label="Close notification details" onclick={() => selected = null}>×</button></div>
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
<div class="form-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
</div>
</div>
{/if}
<style>
.notification-message-header {
display: flex;
justify-content: space-between;
gap: 12px;
height: auto;
min-height: 30px;
align-items: center;
padding: 6px 9px 7px;
margin: 0;
border-bottom: 1px solid color-mix(in srgb, var(--shd-border) 72%, transparent);
}
.notification-message-header h2 {
margin: 0;
line-height: 1.2;
}
.notification-message-header time {
line-height: 1;
white-space: nowrap;
}
</style>