~james/core

core/web/src/lib/components/notifications/NotificationBell.svelte -rw-r--r-- 4.1 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<script lang="ts">
	import { untrack } from "svelte";
	import { resolve } from "$app/paths";
	import ArrowRight from "$icon/arrow-right";
	import Bell from "$icon/bell";
	import Button, { buttonIcon } from "#lib/components/ui/Button.svelte";
	import Dropdown from "#lib/components/ui/Dropdown.svelte";
	import { createLoad } from "#lib/action.svelte.ts";
	import {
		createFixtureNotifications,
		getNotifications,
		type Notifications
	} from "#lib/notifications.svelte.ts";
	import { notificationCategory } from "#lib/components/notifications/notification.ts";
	import NotificationList from "#lib/components/notifications/NotificationList.svelte";
	import NotificationToolbar from "#lib/components/notifications/NotificationToolbar.svelte";
	import type {
		CategoryFilter,
		NotificationSummary,
		ReadFilter
	} from "#lib/components/notifications/types.ts";

	interface Props {
		/** optional fixture override for storybook; omitted → loads from deliberi */
		notifications?: NotificationSummary[];
	}

	let { notifications: fixtures }: Props = $props();

	const shared: Notifications = untrack(() =>
		fixtures
			? createFixtureNotifications(fixtures)
			: (getNotifications() ?? createFixtureNotifications([]))
	);

	const PREVIEW_LIMIT = 5;

	let readFilter = $state<ReadFilter>("unread");
	let categoryFilter = $state<CategoryFilter>("all");

	const previewLoad = createLoad(() => shared.list({ read: readFilter, limit: PREVIEW_LIMIT }));

	const loaded = $derived(previewLoad.data ?? []);
	const all = $derived(shared.overlay(loaded));
	const badge = $derived(shared.unread > 99 ? "99+" : String(shared.unread));

	const unreadOnLoad = $derived(new Set(loaded.filter((n) => !n.read).map((n) => n.uri)));

	const preview = $derived(
		all
			.filter((n) => readFilter !== "unread" || unreadOnLoad.has(n.uri))
			.filter(
				(n) => categoryFilter === "all" || notificationCategory(n.type) === categoryFilter
			)
			.slice(0, PREVIEW_LIMIT)
	);

	const dismiss = (event: MouseEvent & { currentTarget: HTMLAnchorElement }) => {
		const popover = event.currentTarget.closest("[popover]");
		if (popover instanceof HTMLElement) popover.hidePopover();
	};

	const viewAll = $derived(
		resolve(
			(categoryFilter === "all"
				? `/notifications?read=${readFilter}`
				: `/notifications?read=${readFilter}&category=${categoryFilter}`) as "/"
		)
	);
</script>

{#snippet count()}
	{#if shared.unread > 0}
		<span
			data-testid="unreadCount"
			data-count={shared.unread}
			class="pointer-events-none absolute -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-background-info-emphasis px-2xs typography-paragraph-mini text-foreground-on-emphasis"
			aria-hidden="true"
		>
			{badge}
		</span>
	{/if}
{/snippet}

<div class="relative flex md:hidden">
	<Button
		variant="ghost"
		size="small"
		href={resolve("/notifications")}
		icon={Bell}
		aria-label="Notifications, {shared.unread} unread"
	/>
	{@render count()}
</div>

<div class="relative hidden md:flex">
	<Dropdown
		square
		id="notifications-menu"
		group="topbar"
		variant="button"
		buttonVariant="ghost"
		size="small"
		align="right"
		label="Notifications, {shared.unread} unread"
		menuClass="w-[36rem] divide-y divide-border-default p-0 [--panel-border:0px]"
	>
		{#snippet trigger()}
			<Bell class={buttonIcon({ size: "regular" })} aria-hidden="true" />
		{/snippet}

		<div class="p-xs">
			<NotificationToolbar
				{readFilter}
				{categoryFilter}
				hasNotifications={preview.length > 0}
				preferences={false}
				onReadFilter={(filter) => (readFilter = filter)}
				onCategoryFilter={(filter) => (categoryFilter = filter)}
				onMarkAllRead={shared.markAllRead}
			/>
		</div>

		<NotificationList
			loading={previewLoad.loading && !previewLoad.data}
			notifications={preview}
			{readFilter}
			border={false}
			onToggleRead={shared.setRead}
		/>

		<a
			href={viewAll}
			onclick={dismiss}
			class="flex items-center justify-end gap-2xs p-xs typography-paragraph-small text-foreground-muted no-underline hover:bg-background-subtle hover:text-foreground-default hover:no-underline md:px-md"
		>
			View all
			<ArrowRight class="size-3.5" aria-hidden="true" />
		</a>
	</Dropdown>

	{@render count()}
</div>