Add user notification inbox and delivery controls
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user