@actuate-media/cms-admin 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  2. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  4. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  5. package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
  6. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  7. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  10. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/forms/Drawer.d.ts +13 -0
  13. package/dist/components/forms/Drawer.d.ts.map +1 -0
  14. package/dist/components/forms/Drawer.js +13 -0
  15. package/dist/components/forms/Drawer.js.map +1 -0
  16. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  17. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  18. package/dist/components/forms/EmbedPanel.js +91 -0
  19. package/dist/components/forms/EmbedPanel.js.map +1 -0
  20. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  21. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  22. package/dist/components/forms/FieldsPanel.js +123 -0
  23. package/dist/components/forms/FieldsPanel.js.map +1 -0
  24. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  25. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  26. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  27. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  28. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  29. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  30. package/dist/components/forms/NotificationsPanel.js +80 -0
  31. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  32. package/dist/components/forms/primitives.d.ts +42 -0
  33. package/dist/components/forms/primitives.d.ts.map +1 -0
  34. package/dist/components/forms/primitives.js +96 -0
  35. package/dist/components/forms/primitives.js.map +1 -0
  36. package/dist/layout/Sidebar.d.ts +5 -0
  37. package/dist/layout/Sidebar.d.ts.map +1 -1
  38. package/dist/layout/Sidebar.js +43 -3
  39. package/dist/layout/Sidebar.js.map +1 -1
  40. package/dist/lib/forms-events.d.ts +17 -0
  41. package/dist/lib/forms-events.d.ts.map +1 -0
  42. package/dist/lib/forms-events.js +20 -0
  43. package/dist/lib/forms-events.js.map +1 -0
  44. package/dist/lib/forms-service.d.ts +80 -0
  45. package/dist/lib/forms-service.d.ts.map +1 -0
  46. package/dist/lib/forms-service.js +144 -0
  47. package/dist/lib/forms-service.js.map +1 -0
  48. package/dist/views/Forms.d.ts.map +1 -1
  49. package/dist/views/Forms.js +119 -17
  50. package/dist/views/Forms.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  53. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
  54. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  55. package/src/components/forms/Drawer.tsx +70 -0
  56. package/src/components/forms/EmbedPanel.tsx +173 -0
  57. package/src/components/forms/FieldsPanel.tsx +385 -0
  58. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  59. package/src/components/forms/NotificationsPanel.tsx +240 -0
  60. package/src/components/forms/primitives.tsx +200 -0
  61. package/src/layout/Sidebar.tsx +72 -6
  62. package/src/lib/forms-events.ts +32 -0
  63. package/src/lib/forms-service.ts +244 -0
  64. package/src/views/Forms.tsx +343 -106
@@ -0,0 +1,240 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Notifications tab of the Form Schema drawer. Persists per-form notification
5
+ * settings (recipients, reply-to, subject/message templates, field inclusion,
6
+ * autoresponder). Actual email/webhook delivery + the "send test" action are
7
+ * wired in a later phase (PR 6); this panel owns the configuration only.
8
+ */
9
+ import { useEffect, useState } from 'react'
10
+ import * as Switch from '@radix-ui/react-switch'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ fetchNotificationSettings,
14
+ updateNotificationSettings,
15
+ type FormNotificationSettings,
16
+ } from '../../lib/forms-service.js'
17
+ import { emitFormsChanged } from '../../lib/forms-events.js'
18
+ import { FormsErrorState, FormsLoading, btnPrimary } from './primitives.js'
19
+
20
+ const inputCls =
21
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
22
+
23
+ export function NotificationsPanel({
24
+ formId,
25
+ onChanged,
26
+ }: {
27
+ formId: string
28
+ /** Called after a successful save so the list can refresh the notify state. */
29
+ onChanged?: () => void
30
+ }) {
31
+ const [settings, setSettings] = useState<FormNotificationSettings | null>(null)
32
+ const [loading, setLoading] = useState(true)
33
+ const [error, setError] = useState<string | null>(null)
34
+ const [saving, setSaving] = useState(false)
35
+
36
+ useEffect(() => {
37
+ let active = true
38
+ setLoading(true)
39
+ setError(null)
40
+ fetchNotificationSettings(formId)
41
+ .then((s) => {
42
+ if (active) setSettings(s)
43
+ })
44
+ .catch((e: unknown) => {
45
+ if (active)
46
+ setError(e instanceof Error ? e.message : 'Failed to load notification settings')
47
+ })
48
+ .finally(() => {
49
+ if (active) setLoading(false)
50
+ })
51
+ return () => {
52
+ active = false
53
+ }
54
+ }, [formId])
55
+
56
+ function patch(p: Partial<FormNotificationSettings>) {
57
+ setSettings((cur) => (cur ? { ...cur, ...p } : cur))
58
+ }
59
+
60
+ async function save() {
61
+ if (!settings) return
62
+ setSaving(true)
63
+ const res = await updateNotificationSettings(formId, settings)
64
+ setSaving(false)
65
+ if (res.error) {
66
+ toast.error(res.error)
67
+ return
68
+ }
69
+ if (res.data) setSettings(res.data)
70
+ toast.success('Notification settings saved.')
71
+ emitFormsChanged()
72
+ onChanged?.()
73
+ }
74
+
75
+ if (loading) return <FormsLoading label="Loading notification settings…" />
76
+ if (error) return <FormsErrorState message={error} />
77
+ if (!settings) return null
78
+
79
+ return (
80
+ <div className="space-y-4">
81
+ <ToggleRow
82
+ label="Enable notifications"
83
+ help="Email the recipients below when a new entry arrives."
84
+ checked={settings.enabled}
85
+ onChange={(v) => patch({ enabled: v })}
86
+ />
87
+
88
+ <fieldset
89
+ disabled={!settings.enabled}
90
+ className="space-y-4 transition-opacity disabled:opacity-50"
91
+ >
92
+ <Field label="Recipients" hint="Comma-separated email addresses">
93
+ <input
94
+ type="text"
95
+ value={settings.recipients.join(', ')}
96
+ onChange={(e) =>
97
+ patch({
98
+ recipients: e.target.value
99
+ .split(',')
100
+ .map((s) => s.trim())
101
+ .filter(Boolean),
102
+ })
103
+ }
104
+ placeholder="team@example.com"
105
+ className={inputCls}
106
+ />
107
+ </Field>
108
+
109
+ <Field label="Reply-to field key" hint="Field whose value is used as the reply-to address">
110
+ <input
111
+ type="text"
112
+ value={settings.replyToFieldKey ?? ''}
113
+ onChange={(e) => patch({ replyToFieldKey: e.target.value || null })}
114
+ placeholder="email"
115
+ className={inputCls}
116
+ />
117
+ </Field>
118
+
119
+ <Field label="Subject template">
120
+ <input
121
+ type="text"
122
+ value={settings.subjectTemplate ?? ''}
123
+ onChange={(e) => patch({ subjectTemplate: e.target.value })}
124
+ placeholder="New submission from {{name}}"
125
+ className={inputCls}
126
+ />
127
+ </Field>
128
+
129
+ <Field label="Message template">
130
+ <textarea
131
+ rows={3}
132
+ value={settings.messageTemplate ?? ''}
133
+ onChange={(e) => patch({ messageTemplate: e.target.value })}
134
+ className={inputCls}
135
+ />
136
+ </Field>
137
+
138
+ <ToggleRow
139
+ label="Include all fields"
140
+ help="Append every submitted field to the notification body."
141
+ checked={settings.includeFields ?? true}
142
+ onChange={(v) => patch({ includeFields: v })}
143
+ />
144
+ <ToggleRow
145
+ label="Exclude sensitive fields"
146
+ help="Omit fields marked as sensitive/PII from the email."
147
+ checked={settings.excludeSensitiveFields ?? false}
148
+ onChange={(v) => patch({ excludeSensitiveFields: v })}
149
+ />
150
+
151
+ <div className="border-border border-t pt-4">
152
+ <ToggleRow
153
+ label="Auto-response email"
154
+ help="Send a confirmation email back to the submitter."
155
+ checked={settings.autoresponderEnabled ?? false}
156
+ onChange={(v) => patch({ autoresponderEnabled: v })}
157
+ />
158
+ {settings.autoresponderEnabled && (
159
+ <div className="mt-3 space-y-3">
160
+ <Field label="Auto-response subject">
161
+ <input
162
+ type="text"
163
+ value={settings.autoresponderSubject ?? ''}
164
+ onChange={(e) => patch({ autoresponderSubject: e.target.value })}
165
+ className={inputCls}
166
+ />
167
+ </Field>
168
+ <Field label="Auto-response body">
169
+ <textarea
170
+ rows={3}
171
+ value={settings.autoresponderBody ?? ''}
172
+ onChange={(e) => patch({ autoresponderBody: e.target.value })}
173
+ className={inputCls}
174
+ />
175
+ </Field>
176
+ </div>
177
+ )}
178
+ </div>
179
+ </fieldset>
180
+
181
+ <p className="text-muted-foreground text-xs">
182
+ Email &amp; webhook delivery (including the test action) is wired in an upcoming release.
183
+ Settings saved here apply automatically once delivery is enabled.
184
+ </p>
185
+
186
+ <div className="flex justify-end">
187
+ <button className={btnPrimary} onClick={save} disabled={saving}>
188
+ {saving ? 'Saving…' : 'Save notifications'}
189
+ </button>
190
+ </div>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ function ToggleRow({
196
+ label,
197
+ help,
198
+ checked,
199
+ onChange,
200
+ }: {
201
+ label: string
202
+ help: string
203
+ checked: boolean
204
+ onChange: (v: boolean) => void
205
+ }) {
206
+ return (
207
+ <div className="flex items-center justify-between gap-3">
208
+ <span className="min-w-0">
209
+ <span className="text-foreground block text-sm font-medium">{label}</span>
210
+ <span className="text-muted-foreground block text-sm">{help}</span>
211
+ </span>
212
+ <Switch.Root
213
+ checked={checked}
214
+ onCheckedChange={onChange}
215
+ aria-label={label}
216
+ className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
217
+ >
218
+ <Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
219
+ </Switch.Root>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ function Field({
225
+ label,
226
+ hint,
227
+ children,
228
+ }: {
229
+ label: string
230
+ hint?: string
231
+ children: React.ReactNode
232
+ }) {
233
+ return (
234
+ <label className="block">
235
+ <span className="text-foreground mb-1 block text-sm font-medium">{label}</span>
236
+ {children}
237
+ {hint && <span className="text-muted-foreground mt-1 block text-xs">{hint}</span>}
238
+ </label>
239
+ )
240
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shared Forms UI primitives — token-only, accessible building blocks used
5
+ * across the Forms list, schema drawer, and (later) the entries views. No raw
6
+ * palette colours: field-type and status hues map onto the theme's semantic
7
+ * tokens (success / warning / info / destructive / primary / muted) so they
8
+ * track light/dark mode and brand overrides automatically.
9
+ */
10
+ import type { ReactNode } from 'react'
11
+ import { AlertTriangle, Loader2 } from 'lucide-react'
12
+ import type { FormFieldType, FormStatus } from '../../lib/forms-service.js'
13
+
14
+ // ─── Field-type badges ─────────────────────────────────────────────────────
15
+
16
+ /** Map a field type onto a semantic colour pair (background + text). */
17
+ function fieldTypeTone(type: FormFieldType): string {
18
+ switch (type) {
19
+ case 'email':
20
+ case 'url':
21
+ case 'date':
22
+ case 'number':
23
+ return 'bg-info/10 text-info'
24
+ case 'phone':
25
+ case 'textarea':
26
+ return 'bg-primary/10 text-primary'
27
+ case 'select':
28
+ case 'multiselect':
29
+ case 'radio':
30
+ return 'bg-warning/10 text-warning'
31
+ case 'checkbox':
32
+ case 'consent':
33
+ return 'bg-success/10 text-success'
34
+ case 'file':
35
+ return 'bg-destructive/10 text-destructive'
36
+ default:
37
+ return 'bg-muted text-muted-foreground'
38
+ }
39
+ }
40
+
41
+ const FIELD_TYPE_LABELS: Partial<Record<FormFieldType, string>> = {
42
+ text: 'TEXT',
43
+ email: 'EMAIL',
44
+ phone: 'PHONE',
45
+ textarea: 'TEXTAREA',
46
+ select: 'SELECT',
47
+ multiselect: 'MULTI',
48
+ radio: 'RADIO',
49
+ checkbox: 'CHECKBOX',
50
+ date: 'DATE',
51
+ number: 'NUMBER',
52
+ url: 'URL',
53
+ hidden: 'HIDDEN',
54
+ file: 'FILE',
55
+ consent: 'CONSENT',
56
+ richtext: 'CONTENT',
57
+ divider: 'DIVIDER',
58
+ honeypot: 'HONEYPOT',
59
+ }
60
+
61
+ export function FieldTypeBadge({ type }: { type: FormFieldType }) {
62
+ return (
63
+ <span
64
+ className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${fieldTypeTone(type)}`}
65
+ >
66
+ {FIELD_TYPE_LABELS[type] ?? type}
67
+ </span>
68
+ )
69
+ }
70
+
71
+ // ─── Status badges ─────────────────────────────────────────────────────────
72
+
73
+ const STATUS_META: Record<FormStatus, { label: string; cls: string; dot: string }> = {
74
+ active: { label: 'Active', cls: 'bg-success/10 text-success', dot: 'bg-success' },
75
+ draft: { label: 'Draft', cls: 'bg-warning/10 text-warning', dot: 'bg-warning' },
76
+ archived: {
77
+ label: 'Archived',
78
+ cls: 'bg-muted text-muted-foreground',
79
+ dot: 'bg-muted-foreground',
80
+ },
81
+ }
82
+
83
+ export function FormStatusBadge({ status }: { status: FormStatus }) {
84
+ const meta = STATUS_META[status] ?? STATUS_META.draft
85
+ return (
86
+ <span
87
+ className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}
88
+ >
89
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
90
+ {meta.label}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ /** Small red count badge for unread submissions. Hidden when count is 0. */
96
+ export function UnreadBadge({ count, className = '' }: { count: number; className?: string }) {
97
+ if (count <= 0) return null
98
+ return (
99
+ <span
100
+ className={`bg-destructive text-destructive-foreground inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums ${className}`}
101
+ aria-label={`${count} unread`}
102
+ >
103
+ {count > 99 ? '99+' : count}
104
+ </span>
105
+ )
106
+ }
107
+
108
+ // ─── Summary chips ───────────────────────────────────────────────────────
109
+
110
+ export function SummaryChip({
111
+ value,
112
+ label,
113
+ tone = 'neutral',
114
+ }: {
115
+ value: ReactNode
116
+ label: string
117
+ tone?: 'neutral' | 'success' | 'warning' | 'destructive'
118
+ }) {
119
+ const valueCls =
120
+ tone === 'success'
121
+ ? 'text-success'
122
+ : tone === 'warning'
123
+ ? 'text-warning'
124
+ : tone === 'destructive'
125
+ ? 'text-destructive'
126
+ : 'text-foreground'
127
+ return (
128
+ <div className="border-border bg-card flex items-baseline gap-1.5 rounded-lg border px-3 py-2">
129
+ <span className={`text-base font-medium tabular-nums ${valueCls}`}>{value}</span>
130
+ <span className="text-muted-foreground text-sm">{label}</span>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ // ─── States ──────────────────────────────────────────────────────────────
136
+
137
+ export function FormsLoading({ label = 'Loading…' }: { label?: string }) {
138
+ return (
139
+ <div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
140
+ <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
141
+ <span className="sr-only">{label}</span>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ export function FormsEmptyState({
147
+ icon,
148
+ title,
149
+ description,
150
+ action,
151
+ }: {
152
+ icon?: ReactNode
153
+ title: string
154
+ description?: string
155
+ action?: ReactNode
156
+ }) {
157
+ return (
158
+ <div className="border-border flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center">
159
+ {icon && (
160
+ <div className="text-muted-foreground mb-3" aria-hidden>
161
+ {icon}
162
+ </div>
163
+ )}
164
+ <h3 className="text-foreground text-base font-medium">{title}</h3>
165
+ {description && <p className="text-muted-foreground mt-1 max-w-md text-sm">{description}</p>}
166
+ {action && <div className="mt-4">{action}</div>}
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export function FormsErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
172
+ return (
173
+ <div
174
+ className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
175
+ role="alert"
176
+ >
177
+ <AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
178
+ <span className="text-destructive flex-1 text-sm">{message}</span>
179
+ {onRetry && (
180
+ <button
181
+ onClick={onRetry}
182
+ className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm transition-colors"
183
+ >
184
+ Retry
185
+ </button>
186
+ )}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ // ─── Button helpers (token-only) ─────────────────────────────────────────
192
+
193
+ export const btnPrimary =
194
+ 'inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
195
+
196
+ export const btnSecondary =
197
+ 'inline-flex items-center justify-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-base font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
198
+
199
+ export const btnGhostIcon =
200
+ 'inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
@@ -1,8 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { useRef, useState, type ReactNode } from 'react'
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
4
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
5
  import { useTheme } from '../components/ThemeProvider.js'
6
+ import { fetchFormsSidebarCounts } from '../lib/forms-service.js'
7
+ import { onFormsChanged } from '../lib/forms-events.js'
6
8
  import {
7
9
  LayoutDashboard,
8
10
  FileText,
@@ -175,7 +177,8 @@ export function Sidebar({
175
177
  onNavigate,
176
178
  config,
177
179
  }: SidebarProps) {
178
- const navItems = buildNavItems(config)
180
+ const formsUnread = useFormsUnread()
181
+ const navItems = applyFormsBadge(buildNavItems(config), formsUnread)
179
182
 
180
183
  return (
181
184
  <aside
@@ -223,6 +226,45 @@ export function Sidebar({
223
226
  )
224
227
  }
225
228
 
229
+ // ─── Dynamic Forms unread badge ───────────────────────────────────────────
230
+
231
+ /**
232
+ * Live total of unread submissions across active forms, surfaced on the Forms
233
+ * nav item. Refreshes on mount and whenever a forms/entries mutation fires the
234
+ * shared event (mark read, archive, notify toggle, …). Best-effort: a failed
235
+ * fetch simply leaves the badge unset rather than surfacing an error in nav.
236
+ */
237
+ function useFormsUnread(): number {
238
+ const [unread, setUnread] = useState(0)
239
+
240
+ useEffect(() => {
241
+ let active = true
242
+ const load = () => {
243
+ fetchFormsSidebarCounts()
244
+ .then((counts) => {
245
+ if (active) setUnread(counts.reduce((sum, c) => sum + (c.unread ?? 0), 0))
246
+ })
247
+ .catch(() => {
248
+ /* keep the last known value; nav must never break on a fetch error */
249
+ })
250
+ }
251
+ load()
252
+ const off = onFormsChanged(load)
253
+ return () => {
254
+ active = false
255
+ off()
256
+ }
257
+ }, [])
258
+
259
+ return unread
260
+ }
261
+
262
+ /** Return a copy of the nav tree with the live unread count on the Forms item. */
263
+ function applyFormsBadge(items: NavItem[], unread: number): NavItem[] {
264
+ if (unread <= 0) return items
265
+ return items.map((item) => (item.path === '/forms' ? { ...item, badge: unread } : item))
266
+ }
267
+
226
268
  // ─── Rendering ──────────────────────────────────────────────────────────────
227
269
 
228
270
  interface NavRenderContext {
@@ -384,6 +426,8 @@ function NavLink({
384
426
  const Icon = item.icon
385
427
  const isActive = isPathActive(ctx.currentPath, item.path)
386
428
 
429
+ const badge = item.badge && item.badge > 0 ? item.badge : 0
430
+
387
431
  const button = (
388
432
  <button
389
433
  onClick={() => ctx.onNavigate(item.path)}
@@ -397,15 +441,32 @@ function NavLink({
397
441
  title={ctx.collapsed ? item.label : ''}
398
442
  aria-current={isActive ? 'page' : undefined}
399
443
  >
400
- {!nested && <Icon className="h-5 w-5 shrink-0" aria-hidden />}
401
- {nested && !ctx.collapsed && (
402
- <Icon className="text-sidebar-foreground/60 h-4 w-4 shrink-0" aria-hidden />
403
- )}
444
+ <span className="relative flex shrink-0 items-center">
445
+ {!nested && <Icon className="h-5 w-5 shrink-0" aria-hidden />}
446
+ {nested && !ctx.collapsed && (
447
+ <Icon className="text-sidebar-foreground/60 h-4 w-4 shrink-0" aria-hidden />
448
+ )}
449
+ {/* Collapsed rail: a dot signals unread without room for a number. */}
450
+ {ctx.collapsed && badge > 0 && (
451
+ <span
452
+ className="bg-destructive absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full"
453
+ aria-hidden
454
+ />
455
+ )}
456
+ </span>
404
457
  {!ctx.collapsed && (
405
458
  <span className={`truncate font-medium ${nested ? 'text-[13px]' : 'text-sm'}`}>
406
459
  {item.label}
407
460
  </span>
408
461
  )}
462
+ {!ctx.collapsed && badge > 0 && (
463
+ <span
464
+ className="bg-destructive text-destructive-foreground ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
465
+ aria-label={`${badge} unread`}
466
+ >
467
+ {badge > 99 ? '99+' : badge}
468
+ </span>
469
+ )}
409
470
  </button>
410
471
  )
411
472
 
@@ -561,6 +622,11 @@ export interface NavItem {
561
622
  group?: string
562
623
  /** Indented children rendered immediately after this item. */
563
624
  children?: NavItem[]
625
+ /**
626
+ * Optional unread/notification count. Rendered as a red pill (expanded)
627
+ * or a dot (collapsed). Falsy / zero values render nothing.
628
+ */
629
+ badge?: number
564
630
  }
565
631
 
566
632
  /**
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Forms event bus — a minimal pub/sub so views that mutate submission state
3
+ * (mark read/unread, star, archive) or form state (create, notify toggle,
4
+ * archive) can tell the sidebar to refresh its unread badges immediately,
5
+ * without threading a refresh callback through the whole component tree.
6
+ *
7
+ * Deliberately tiny and dependency-free: one event, synchronous fan-out,
8
+ * SSR-safe. Subscribers must unsubscribe on unmount via the returned
9
+ * disposer to avoid leaks.
10
+ */
11
+ type Listener = () => void
12
+
13
+ const listeners = new Set<Listener>()
14
+
15
+ /** Subscribe to "forms data changed" notifications. Returns an unsubscribe fn. */
16
+ export function onFormsChanged(listener: Listener): () => void {
17
+ listeners.add(listener)
18
+ return () => {
19
+ listeners.delete(listener)
20
+ }
21
+ }
22
+
23
+ /** Notify all subscribers that forms/submission counts may have changed. */
24
+ export function emitFormsChanged(): void {
25
+ for (const listener of listeners) {
26
+ try {
27
+ listener()
28
+ } catch {
29
+ // A misbehaving subscriber must not break sibling subscribers.
30
+ }
31
+ }
32
+ }