Add reusable segmented inbox filters

This commit is contained in:
JB
2026-07-25 15:42:34 -04:00
parent 29f4de807a
commit 8b7b747866
7 changed files with 134 additions and 8 deletions
@@ -9,12 +9,24 @@ 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 filtering = url.searchParams.get('filtering') === '1';
const categories = filtering
? url.searchParams.getAll('channel').filter((value) => NOTIFICATION_CATEGORIES.includes(value as any))
: [...NOTIFICATION_CATEGORIES];
const states = filtering
? url.searchParams.getAll('state').filter((value) => value === 'unread' || value === 'read')
: ['unread', 'read'];
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');
if (categories.length === 0 || states.length === 0) {
conditions.push('1=0');
} else {
if (categories.length < NOTIFICATION_CATEGORIES.length) {
conditions.push(`n.category IN (${categories.map(() => '?').join(',')})`);
args.push(...categories);
}
if (states.length === 1) conditions.push(states[0] === 'unread' ? 'n.read_at IS NULL' : 'n.read_at IS NOT 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);
@@ -34,7 +46,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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)) };
return { notifications, preferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
};
export const actions: Actions = {
+12 -1
View File
@@ -3,6 +3,7 @@
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SystemMessageBody from '$lib/components/SystemMessageBody.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
let settingsOpen = $state(false);
let selected = $state<any>(null);
@@ -40,6 +41,9 @@
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
browserPermission = await Notification.requestPermission();
}
function applyFilters() {
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
}
</script>
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
@@ -72,7 +76,14 @@
</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>
<div class="notification-toolbar">
<form class="notification-filter-console">
<input type="hidden" name="filtering" value="1"/>
<SegmentedChoice legend="CHANNELS" name="channel" mode="checkbox" options={Object.entries(labels).map(([value, label]) => ({ value, label }))} selected={data.filters.categories} onchange={applyFilters}/>
<SegmentedChoice legend="MESSAGE STATE" name="state" mode="checkbox" options={[{value:'unread',label:'UNREAD'},{value:'read',label:'READ'}]} selected={data.filters.states} onchange={applyFilters}/>
</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}
+5 -2
View File
@@ -6,6 +6,7 @@
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
@@ -26,6 +27,8 @@
let handledResultToken = $state('');
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
const attributeTypeOptions = attributeTypes.map((type) => ({ value: type, label: type, icon: iconName(type) }));
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
@@ -131,8 +134,8 @@
<div class="protocol-identity-grid">
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON"/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
<div class="protocol-classification-stack">
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
</div>
</div>
{@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)}