Files
shd.build/src/routes/account/notifications/+page.svelte
T

122 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>