@actuate-media/cms-admin 0.21.1 → 0.23.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 (83) hide show
  1. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  2. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  4. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  5. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  6. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  8. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
  10. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  11. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  12. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  14. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  15. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  18. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  19. package/dist/actuate-admin.css +1 -1
  20. package/dist/components/forms/Drawer.d.ts +13 -0
  21. package/dist/components/forms/Drawer.d.ts.map +1 -0
  22. package/dist/components/forms/Drawer.js +13 -0
  23. package/dist/components/forms/Drawer.js.map +1 -0
  24. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  25. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  26. package/dist/components/forms/EmbedPanel.js +91 -0
  27. package/dist/components/forms/EmbedPanel.js.map +1 -0
  28. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  29. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  30. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  31. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  32. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  33. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  34. package/dist/components/forms/FieldsPanel.js +123 -0
  35. package/dist/components/forms/FieldsPanel.js.map +1 -0
  36. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  37. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  38. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  39. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  40. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  41. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  42. package/dist/components/forms/NotificationsPanel.js +80 -0
  43. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  44. package/dist/components/forms/primitives.d.ts +57 -0
  45. package/dist/components/forms/primitives.d.ts.map +1 -0
  46. package/dist/components/forms/primitives.js +131 -0
  47. package/dist/components/forms/primitives.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts +5 -0
  49. package/dist/layout/Sidebar.d.ts.map +1 -1
  50. package/dist/layout/Sidebar.js +43 -3
  51. package/dist/layout/Sidebar.js.map +1 -1
  52. package/dist/lib/forms-events.d.ts +17 -0
  53. package/dist/lib/forms-events.d.ts.map +1 -0
  54. package/dist/lib/forms-events.js +20 -0
  55. package/dist/lib/forms-events.js.map +1 -0
  56. package/dist/lib/forms-service.d.ts +114 -0
  57. package/dist/lib/forms-service.d.ts.map +1 -0
  58. package/dist/lib/forms-service.js +210 -0
  59. package/dist/lib/forms-service.js.map +1 -0
  60. package/dist/views/FormSubmissions.d.ts.map +1 -1
  61. package/dist/views/FormSubmissions.js +224 -77
  62. package/dist/views/FormSubmissions.js.map +1 -1
  63. package/dist/views/Forms.d.ts.map +1 -1
  64. package/dist/views/Forms.js +135 -17
  65. package/dist/views/Forms.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  68. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  69. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
  70. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  71. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  72. package/src/components/forms/Drawer.tsx +70 -0
  73. package/src/components/forms/EmbedPanel.tsx +173 -0
  74. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  75. package/src/components/forms/FieldsPanel.tsx +385 -0
  76. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  77. package/src/components/forms/NotificationsPanel.tsx +240 -0
  78. package/src/components/forms/primitives.tsx +300 -0
  79. package/src/layout/Sidebar.tsx +72 -6
  80. package/src/lib/forms-events.ts +32 -0
  81. package/src/lib/forms-service.ts +343 -0
  82. package/src/views/FormSubmissions.tsx +529 -394
  83. package/src/views/Forms.tsx +381 -106
@@ -1,138 +1,413 @@
1
1
  'use client'
2
2
 
3
- import { Plus, MoreVertical, FileText, Send, Search, Loader2, AlertTriangle } from 'lucide-react'
4
- import { useState, useMemo } from 'react'
3
+ /**
4
+ * Forms list manage forms and view high-level submission status. Matches the
5
+ * Forms list screenshot: summary chips, search + status filter pills, and a
6
+ * compact table (Form / Fields / Status / Entries / Last Entry / Notify /
7
+ * Schema). All data flows through `lib/forms-service.ts`; the Schema action
8
+ * opens the right-side `FormSchemaDrawer`.
9
+ */
10
+ import { ChevronRight, ClipboardList, Plus, Search, Settings2 } from 'lucide-react'
11
+ import * as Switch from '@radix-ui/react-switch'
12
+ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
13
+ import { toast } from 'sonner'
5
14
  import { sortByRelevance } from '../lib/search.js'
6
- import { useApiData } from '../lib/useApiData.js'
15
+ import {
16
+ fetchForms,
17
+ fetchFormStats,
18
+ setNotifyEnabled,
19
+ type FormListItem,
20
+ type FormStats,
21
+ type FormStatus,
22
+ } from '../lib/forms-service.js'
23
+ import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
24
+ import { FormSchemaDrawer } from '../components/forms/FormSchemaDrawer.js'
25
+ import {
26
+ FieldTypeBadge,
27
+ FormStatusBadge,
28
+ FormsEmptyState,
29
+ FormsErrorState,
30
+ FormsLoading,
31
+ FormsPagination,
32
+ SummaryChip,
33
+ UnreadBadge,
34
+ btnPrimary,
35
+ } from '../components/forms/primitives.js'
36
+
37
+ const PAGE_SIZE = 10
7
38
 
8
39
  export interface FormsProps {
9
40
  onNavigate?: (path: string) => void
10
41
  }
11
42
 
43
+ type StatusFilter = 'all' | FormStatus
44
+
45
+ const FILTERS: { id: StatusFilter; label: string }[] = [
46
+ { id: 'all', label: 'All' },
47
+ { id: 'active', label: 'Active' },
48
+ { id: 'draft', label: 'Draft' },
49
+ { id: 'archived', label: 'Archived' },
50
+ ]
51
+
52
+ function formatRelative(iso: string | null | undefined): string {
53
+ if (!iso) return '—'
54
+ const then = new Date(iso).getTime()
55
+ if (Number.isNaN(then)) return '—'
56
+ const diff = Date.now() - then
57
+ const mins = Math.round(diff / 60000)
58
+ if (mins < 1) return 'just now'
59
+ if (mins < 60) return `${mins} min ago`
60
+ const hrs = Math.round(mins / 60)
61
+ if (hrs < 24) return `${hrs} hour${hrs === 1 ? '' : 's'} ago`
62
+ const days = Math.round(hrs / 24)
63
+ if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
64
+ return new Date(iso).toLocaleDateString(undefined, {
65
+ month: 'short',
66
+ day: 'numeric',
67
+ year: 'numeric',
68
+ })
69
+ }
70
+
12
71
  export function Forms({ onNavigate }: FormsProps) {
13
- const { data, loading, error, refetch } = useApiData<any[]>('/forms')
14
- const [searchQuery, setSearchQuery] = useState('')
72
+ const [forms, setForms] = useState<FormListItem[]>([])
73
+ const [stats, setStats] = useState<FormStats | null>(null)
74
+ const [loading, setLoading] = useState(true)
75
+ const [error, setError] = useState<string | null>(null)
76
+ const [search, setSearch] = useState('')
77
+ const [filter, setFilter] = useState<StatusFilter>('all')
78
+ const [page, setPage] = useState(1)
79
+ const [schemaForm, setSchemaForm] = useState<FormListItem | null>(null)
80
+ const [schemaOpen, setSchemaOpen] = useState(false)
15
81
 
16
- const forms = data ?? []
82
+ const load = useCallback(async () => {
83
+ setLoading(true)
84
+ setError(null)
85
+ try {
86
+ const [formsData, statsData] = await Promise.all([fetchForms(), fetchFormStats()])
87
+ setForms(formsData)
88
+ setStats(statsData)
89
+ } catch (e) {
90
+ setError(e instanceof Error ? e.message : 'Failed to load forms')
91
+ } finally {
92
+ setLoading(false)
93
+ }
94
+ }, [])
95
+
96
+ useEffect(() => {
97
+ void load()
98
+ }, [load])
17
99
 
18
- const filteredAndSorted = useMemo(() => {
19
- let results = forms.filter(
20
- (form: any) =>
21
- (form.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
22
- (form.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
23
- )
100
+ // Refresh when another surface signals a forms/submission change.
101
+ useEffect(() => onFormsChanged(() => void load()), [load])
24
102
 
25
- if (searchQuery.trim()) {
26
- results = sortByRelevance(results, searchQuery, (f: any) => [f.name, f.description ?? ''])
103
+ const filtered = useMemo(() => {
104
+ let results = forms
105
+ if (filter !== 'all') results = results.filter((f) => f.status === filter)
106
+ const q = search.trim().toLowerCase()
107
+ if (q) {
108
+ results = results.filter(
109
+ (f) =>
110
+ f.name.toLowerCase().includes(q) ||
111
+ f.slug.toLowerCase().includes(q) ||
112
+ (f.description ?? '').toLowerCase().includes(q) ||
113
+ f.fields.some(
114
+ (field) =>
115
+ field.label.toLowerCase().includes(q) || field.type.toLowerCase().includes(q),
116
+ ),
117
+ )
118
+ results = sortByRelevance(results, search, (f) => [
119
+ f.name,
120
+ f.slug,
121
+ f.description ?? '',
122
+ ...f.fields.map((field) => field.label),
123
+ ])
27
124
  }
28
125
  return results
29
- }, [forms, searchQuery])
126
+ }, [forms, filter, search])
30
127
 
31
- if (loading) {
32
- return (
33
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
34
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
35
- </div>
36
- )
37
- }
128
+ const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
38
129
 
39
- return (
40
- <div className="p-3 pr-6 sm:p-4 sm:pr-8">
41
- {error && (
42
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
43
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
44
- <span className="flex-1 text-sm text-red-800">{error}</span>
45
- <button
46
- onClick={refetch}
47
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
48
- >
49
- Retry
50
- </button>
51
- </div>
52
- )}
130
+ // Keep the page in range when the filtered set shrinks (filters, refresh).
131
+ useEffect(() => {
132
+ if (page > totalPages) setPage(totalPages)
133
+ }, [page, totalPages])
134
+
135
+ const paged = useMemo(
136
+ () => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
137
+ [filtered, page],
138
+ )
53
139
 
54
- <div className="mb-4 flex items-center justify-between">
140
+ const openSchema = useCallback((form: FormListItem) => {
141
+ setSchemaForm(form)
142
+ setSchemaOpen(true)
143
+ }, [])
144
+
145
+ const handleNotifyToggle = useCallback(async (form: FormListItem, next: boolean) => {
146
+ // Optimistic update; roll back on error.
147
+ setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: next } : f)))
148
+ const res = await setNotifyEnabled(form.id, next)
149
+ if (res.error) {
150
+ setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: !next } : f)))
151
+ toast.error(res.error)
152
+ return
153
+ }
154
+ emitFormsChanged()
155
+ }, [])
156
+
157
+ return (
158
+ <div className="space-y-5 p-6">
159
+ <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
55
160
  <div>
56
- <h1 className="mb-1 text-xl font-semibold text-gray-900 sm:text-2xl">Forms</h1>
57
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total forms</p>
161
+ <h1 className="text-foreground text-2xl font-medium">Forms</h1>
162
+ <p className="text-muted-foreground mt-1 text-sm">
163
+ Manage forms and view incoming submissions
164
+ </p>
58
165
  </div>
59
- <button
60
- onClick={() => onNavigate?.('/forms/new')}
61
- className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
62
- >
63
- <Plus className="h-4 w-4" />
166
+ <button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
167
+ <Plus className="h-4 w-4" aria-hidden />
64
168
  New Form
65
169
  </button>
66
- </div>
170
+ </header>
67
171
 
68
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
69
- <div className="p-3">
70
- <div className="relative">
71
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
72
- <input
73
- type="text"
74
- placeholder="Search forms..."
75
- value={searchQuery}
76
- onChange={(e) => setSearchQuery(e.target.value)}
77
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
78
- />
79
- </div>
172
+ {stats && (
173
+ <div className="flex flex-wrap gap-2">
174
+ <SummaryChip value={stats.totalForms} label={stats.totalForms === 1 ? 'form' : 'forms'} />
175
+ <SummaryChip value={stats.activeForms} label="active" tone="success" />
176
+ <SummaryChip value={stats.totalEntries} label="total entries" />
177
+ <SummaryChip
178
+ value={stats.unreadEntries}
179
+ label="unread"
180
+ tone={stats.unreadEntries > 0 ? 'destructive' : 'neutral'}
181
+ />
80
182
  </div>
81
- </div>
183
+ )}
82
184
 
83
- {filteredAndSorted.length === 0 ? (
84
- <div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
85
- <p className="mb-2 text-sm text-gray-500">No forms yet</p>
86
- <button className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700">
87
- Create your first form
88
- </button>
185
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
186
+ <div className="relative w-full sm:max-w-xs">
187
+ <Search
188
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
189
+ aria-hidden
190
+ />
191
+ <input
192
+ type="search"
193
+ placeholder="Search forms…"
194
+ value={search}
195
+ onChange={(e) => {
196
+ setSearch(e.target.value)
197
+ setPage(1)
198
+ }}
199
+ aria-label="Search forms"
200
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
201
+ />
89
202
  </div>
90
- ) : (
91
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
92
- {filteredAndSorted.map((form: any) => (
93
- <div
94
- key={form.id}
95
- className="rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md"
203
+ <div className="flex gap-1" role="group" aria-label="Filter by status">
204
+ {FILTERS.map((f) => (
205
+ <button
206
+ key={f.id}
207
+ type="button"
208
+ onClick={() => {
209
+ setFilter(f.id)
210
+ setPage(1)
211
+ }}
212
+ aria-pressed={filter === f.id}
213
+ className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
214
+ filter === f.id
215
+ ? 'bg-primary text-primary-foreground'
216
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
217
+ }`}
96
218
  >
97
- <div className="mb-3 flex items-start justify-between">
98
- <div className="flex-1">
99
- <h3 className="mb-1 font-semibold text-gray-900">{form.name}</h3>
100
- <p className="mb-2 text-sm text-gray-600">{form.description}</p>
101
- </div>
102
- <button className="rounded p-1.5 transition-colors hover:bg-gray-100">
103
- <MoreVertical className="h-4 w-4 text-gray-600" />
104
- </button>
105
- </div>
106
- <div className="mb-3 flex items-center gap-4 text-sm text-gray-600">
107
- <span className="flex items-center gap-1">
108
- <FileText className="h-4 w-4" />
109
- {form.fieldCount ??
110
- (Array.isArray(form.fields) ? form.fields.length : form.fields)}{' '}
111
- fields
112
- </span>
113
- <span className="flex items-center gap-1">
114
- <Send className="h-4 w-4" />
115
- {form.submissions} submissions
116
- </span>
117
- </div>
118
- <div className="flex items-center gap-2">
119
- <button
120
- onClick={() => onNavigate?.(`/forms/${form.id}/submissions`)}
121
- className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-blue-700"
122
- >
123
- View Submissions
124
- </button>
125
- <button
126
- onClick={() => onNavigate?.(`/forms/${form.id}/edit`)}
127
- className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm transition-colors hover:bg-gray-50"
128
- >
129
- Edit
130
- </button>
131
- </div>
132
- </div>
219
+ {f.label}
220
+ </button>
133
221
  ))}
134
222
  </div>
223
+ </div>
224
+
225
+ {error ? (
226
+ <FormsErrorState message={error} onRetry={load} />
227
+ ) : loading ? (
228
+ <FormsLoading label="Loading forms…" />
229
+ ) : forms.length === 0 ? (
230
+ <FormsEmptyState
231
+ icon={<ClipboardList className="h-6 w-6" aria-hidden />}
232
+ title="No forms yet"
233
+ description="Create your first form to start collecting submissions."
234
+ action={
235
+ <button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
236
+ <Plus className="h-4 w-4" aria-hidden />
237
+ Create New Form
238
+ </button>
239
+ }
240
+ />
241
+ ) : filtered.length === 0 ? (
242
+ <FormsEmptyState
243
+ title="No forms match your filters"
244
+ description="Try a different search term or status filter."
245
+ action={
246
+ <button
247
+ type="button"
248
+ onClick={() => {
249
+ setSearch('')
250
+ setFilter('all')
251
+ setPage(1)
252
+ }}
253
+ className="text-primary text-sm font-medium hover:underline"
254
+ >
255
+ Clear filters
256
+ </button>
257
+ }
258
+ />
259
+ ) : (
260
+ <FormsTable
261
+ forms={paged}
262
+ onOpenForm={(id) => onNavigate?.(`/forms/${id}/edit`)}
263
+ onOpenEntries={(id) => onNavigate?.(`/forms/${id}/submissions`)}
264
+ onOpenSchema={openSchema}
265
+ onNotifyToggle={handleNotifyToggle}
266
+ footer={
267
+ <FormsPagination
268
+ page={page}
269
+ pageSize={PAGE_SIZE}
270
+ total={filtered.length}
271
+ onPageChange={setPage}
272
+ noun="form"
273
+ label="Forms pagination"
274
+ />
275
+ }
276
+ />
135
277
  )}
278
+
279
+ <FormSchemaDrawer
280
+ form={schemaForm}
281
+ open={schemaOpen}
282
+ onOpenChange={setSchemaOpen}
283
+ onSaved={load}
284
+ />
285
+ </div>
286
+ )
287
+ }
288
+
289
+ function FormsTable({
290
+ forms,
291
+ onOpenForm,
292
+ onOpenEntries,
293
+ onOpenSchema,
294
+ onNotifyToggle,
295
+ footer,
296
+ }: {
297
+ forms: FormListItem[]
298
+ onOpenForm: (id: string) => void
299
+ onOpenEntries: (id: string) => void
300
+ onOpenSchema: (form: FormListItem) => void
301
+ onNotifyToggle: (form: FormListItem, next: boolean) => void
302
+ footer?: ReactNode
303
+ }) {
304
+ return (
305
+ <div className="border-border bg-card rounded-lg border">
306
+ <div className="overflow-x-auto">
307
+ <table className="w-full min-w-[760px] text-sm" aria-label="Forms">
308
+ <thead>
309
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
310
+ <th scope="col" className="px-4 py-2.5 font-medium">
311
+ Form
312
+ </th>
313
+ <th scope="col" className="px-4 py-2.5 font-medium">
314
+ Fields
315
+ </th>
316
+ <th scope="col" className="px-4 py-2.5 font-medium">
317
+ Status
318
+ </th>
319
+ <th scope="col" className="px-4 py-2.5 font-medium">
320
+ Entries
321
+ </th>
322
+ <th scope="col" className="px-4 py-2.5 font-medium">
323
+ Last Entry
324
+ </th>
325
+ <th scope="col" className="px-4 py-2.5 text-center font-medium">
326
+ Notify
327
+ </th>
328
+ <th scope="col" className="px-4 py-2.5 text-right font-medium">
329
+ <span className="sr-only">Schema</span>
330
+ </th>
331
+ </tr>
332
+ </thead>
333
+ <tbody>
334
+ {forms.map((form) => {
335
+ const fieldCount = form.fieldCount ?? form.fields.length
336
+ const unread = form.unreadEntries ?? 0
337
+ const muted = form.status === 'archived'
338
+ return (
339
+ <tr
340
+ key={form.id}
341
+ className={`border-border hover:bg-accent/40 border-b transition-colors last:border-b-0 ${muted ? 'opacity-60' : ''}`}
342
+ >
343
+ <td className="px-4 py-3">
344
+ <button
345
+ type="button"
346
+ onClick={() => onOpenForm(form.id)}
347
+ className="block max-w-xs text-left"
348
+ >
349
+ <span className="text-foreground block truncate font-medium hover:underline">
350
+ {form.name}
351
+ </span>
352
+ <span className="text-muted-foreground block truncate text-xs">
353
+ /{form.slug}
354
+ {form.description ? ` · ${form.description}` : ''}
355
+ </span>
356
+ </button>
357
+ </td>
358
+ <td className="px-4 py-3">
359
+ <div className="flex flex-wrap items-center gap-1">
360
+ {form.fields.slice(0, 4).map((field) => (
361
+ <FieldTypeBadge key={field.id} type={field.type} />
362
+ ))}
363
+ <span className="text-muted-foreground text-xs">{fieldCount} fields</span>
364
+ </div>
365
+ </td>
366
+ <td className="px-4 py-3">
367
+ <FormStatusBadge status={form.status} />
368
+ </td>
369
+ <td className="px-4 py-3">
370
+ <button
371
+ type="button"
372
+ onClick={() => onOpenEntries(form.id)}
373
+ className="text-foreground hover:text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 rounded font-medium tabular-nums focus-visible:ring-2 focus-visible:outline-none"
374
+ aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
375
+ >
376
+ {form.totalEntries ?? 0}
377
+ <UnreadBadge count={unread} />
378
+ <ChevronRight className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
379
+ </button>
380
+ </td>
381
+ <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
382
+ {formatRelative(form.lastEntryAt)}
383
+ </td>
384
+ <td className="px-4 py-3 text-center">
385
+ <Switch.Root
386
+ checked={form.notifyEnabled ?? false}
387
+ onCheckedChange={(v) => onNotifyToggle(form, v)}
388
+ aria-label={`Notifications for ${form.name}`}
389
+ className="bg-destructive data-[state=checked]:bg-success focus-visible:ring-ring relative inline-flex h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
390
+ >
391
+ <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]" />
392
+ </Switch.Root>
393
+ </td>
394
+ <td className="px-4 py-3 text-right">
395
+ <button
396
+ type="button"
397
+ onClick={() => onOpenSchema(form)}
398
+ className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
399
+ >
400
+ <Settings2 className="h-3.5 w-3.5" aria-hidden />
401
+ Schema
402
+ </button>
403
+ </td>
404
+ </tr>
405
+ )
406
+ })}
407
+ </tbody>
408
+ </table>
409
+ </div>
410
+ {footer}
136
411
  </div>
137
412
  )
138
413
  }