@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.
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.js.map +1 -0
- package/dist/components/forms/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +42 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +96 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +43 -3
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +80 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +144 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +119 -17
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +200 -0
- package/src/layout/Sidebar.tsx +72 -6
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +244 -0
- 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 & 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'
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|