122 lines
10 KiB
Svelte
122 lines
10 KiB
Svelte
<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>
|