kilo-cms 0.1.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 (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { CmsSidebar } from './CmsSidebar'
6
+ import { EditorHeader } from './EditorHeader'
7
+ import { SelectField, TextField } from './Field'
8
+ import { useToast } from './Toast'
9
+ import { EditorSkeleton } from './EditorSkeleton'
10
+ import { Button } from './Button'
11
+
12
+ type RoleRow = { id: string; name: string }
13
+ type UserRow = { id: string; name: string; email: string; role: string }
14
+
15
+ export function UserEditor({ id, userName }: { id: string; userName: string }) {
16
+ const router = useRouter()
17
+ const toast = useToast()
18
+ const isNew = id === 'new'
19
+ const [roles, setRoles] = useState<RoleRow[]>([])
20
+ const [form, setForm] = useState({ name: '', email: '', password: '', role: '' })
21
+ const [loading, setLoading] = useState(!isNew)
22
+ const [saving, setSaving] = useState(false)
23
+
24
+ useEffect(() => {
25
+ fetch('/api/admin/roles').then((response) => response.ok ? response.json() : []).then((data: RoleRow[]) => {
26
+ setRoles(data)
27
+ setForm((current) => current.role ? current : { ...current, role: data[0]?.id ?? '' })
28
+ })
29
+ if (isNew) { setLoading(false); return }
30
+ fetch(`/api/admin/users/${id}`).then((response) => response.ok ? response.json() : null).then((data: UserRow | null) => { if (data) setForm({ name: data.name, email: data.email, password: '', role: data.role }) }).finally(() => setLoading(false))
31
+ }, [id, isNew])
32
+
33
+ async function save() {
34
+ if (!form.name.trim() || !form.email.trim() || !form.role) { toast.error('Fill in name, email, and role.'); return }
35
+ if (isNew && form.password.length < 12) { toast.error('Password needs at least 12 characters.'); return }
36
+ setSaving(true)
37
+ try {
38
+ if (isNew) {
39
+ const response = await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
40
+ const data = await response.json()
41
+ if (!response.ok) throw new Error(data.error)
42
+ toast.success('User created.')
43
+ router.push('/admin/users')
44
+ } else {
45
+ const response = await fetch(`/api/admin/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: form.name, email: form.email, role: form.role }) })
46
+ const data = await response.json()
47
+ if (!response.ok) throw new Error(data.error)
48
+ toast.success('User updated.')
49
+ }
50
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not save user') } finally { setSaving(false) }
51
+ }
52
+
53
+ return <main className="cms-dashboard"><CmsSidebar active="users" userName={userName} /><section className="cms-content">
54
+ <EditorHeader compact title={isNew ? 'New user' : form.name || 'Edit user'} backHref="/admin/users" backLabel="All users" />
55
+ {loading ? <EditorSkeleton sections={1} /> : <div className="cms-editor-layout"><div className="cms-editor-form">
56
+ <section className="cms-section">
57
+ <TextField label="Name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
58
+ <TextField label="Email" type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
59
+ {isNew && <TextField label="Password" type="password" hint="At least 12 characters" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />}
60
+ <SelectField label="Role" value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value })} options={roles.map((role) => ({ value: role.id, label: role.name }))} />
61
+ </section>
62
+ </div><aside className="cms-editor-sidebar"><Button variant="positive" type="button" onClick={() => void save()} disabled={saving} icon="↗">{saving ? 'Saving…' : isNew ? 'Create user' : 'Save changes'}</Button></aside></div>}
63
+ </section></main>
64
+ }
@@ -0,0 +1,37 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { ListingSkeleton } from './ListingView'
7
+ import { Button } from './Button'
8
+
9
+ type UserRow = { id: string; name: string; email: string; role: string }
10
+ type RoleRow = { id: string; name: string }
11
+
12
+ export function UsersEditor({ userName }: { userName: string }) {
13
+ const [users, setUsers] = useState<UserRow[]>([])
14
+ const [roles, setRoles] = useState<RoleRow[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [q, setQ] = useState('')
17
+
18
+ useEffect(() => {
19
+ Promise.all([
20
+ fetch('/api/admin/users').then((response) => response.ok ? response.json() : []),
21
+ fetch('/api/admin/roles').then((response) => response.ok ? response.json() : []),
22
+ ]).then(([usersData, rolesData]) => { setUsers(usersData); setRoles(rolesData) }).finally(() => setLoading(false))
23
+ }, [])
24
+
25
+ function roleName(id: string) { return roles.find((role) => role.id === id)?.name ?? id }
26
+ const filtered = q ? users.filter((row) => row.name.toLowerCase().includes(q.toLowerCase()) || row.email.toLowerCase().includes(q.toLowerCase())) : users
27
+
28
+ return <main className="cms-dashboard"><CmsSidebar active="users" userName={userName} /><section className="cms-content">
29
+ <EditorHeader compact title="Users" meta={`${users.length} users`} actions={<Button variant="positive" href="/admin/users/new" icon="↗">Add user</Button>} />
30
+ <div className="cms-filter-bar">
31
+ <input className="cms-search-input" value={q} onChange={(event) => setQ(event.target.value)} placeholder="Search users…" />
32
+ </div>
33
+ {loading ? <ListingSkeleton view="table" columnCount={3} /> : <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Name</th><th>Email</th><th>Role</th></tr></thead><tbody>
34
+ {filtered.map((row) => <tr key={row.id}><td><a className="cms-row-title" href={`/admin/users/${row.id}`}><strong>{row.name}</strong></a></td><td>{row.email}</td><td>{roleName(row.role)}</td></tr>)}
35
+ </tbody></table>{!filtered.length && <p className="cms-empty-state">No users match your search.</p>}</div>}
36
+ </section></main>
37
+ }
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { TextField, ToggleField } from './Field'
6
+ import { CmsSidebar } from './CmsSidebar'
7
+ import { useToast } from './Toast'
8
+ import { formatDateTime } from '../format'
9
+ import { ListingSkeleton } from './ListingView'
10
+ import { Button } from './Button'
11
+ import { webhookEventGroups } from '../webhook-events'
12
+
13
+ type WebhookRow = { id: string; name: string; url: string; events: string[]; enabled: boolean; createdAt: string }
14
+ type Delivery = { id: string; event: string; statusCode: number | null; success: boolean; error: string | null; createdAt: string }
15
+ type WebhookDetail = WebhookRow & { secret: string; deliveries: Delivery[] }
16
+
17
+ function emptyForm() { return { name: '', url: '', events: [] as string[], enabled: true } }
18
+
19
+ export function WebhooksEditor({ userName }: { userName: string }) {
20
+ const toast = useToast()
21
+ const [webhooks, setWebhooks] = useState<WebhookRow[]>([])
22
+ const [loading, setLoading] = useState(true)
23
+ const [form, setForm] = useState(emptyForm())
24
+ const [creating, setCreating] = useState(false)
25
+ const [newSecret, setNewSecret] = useState<string | null>(null)
26
+ const [selected, setSelected] = useState<WebhookDetail | null>(null)
27
+ const [saving, setSaving] = useState(false)
28
+
29
+ function load() { setLoading(true); fetch('/api/admin/webhooks').then((response) => response.ok ? response.json() : []).then(setWebhooks).finally(() => setLoading(false)) }
30
+ useEffect(() => { load() }, [])
31
+
32
+ function toggleEvent(event: string) { setForm((current) => ({ ...current, events: current.events.includes(event) ? current.events.filter((item) => item !== event) : [...current.events, event] })) }
33
+
34
+ async function create() {
35
+ if (!form.name.trim() || !form.url.trim()) { toast.error('Give the webhook a name and URL.'); return }
36
+ setCreating(true)
37
+ try {
38
+ const response = await fetch('/api/admin/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) })
39
+ const data = await response.json()
40
+ if (!response.ok) throw new Error(data.error)
41
+ setNewSecret(data.secret)
42
+ setForm(emptyForm())
43
+ load()
44
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not create webhook') } finally { setCreating(false) }
45
+ }
46
+
47
+ async function openWebhook(id: string) {
48
+ const response = await fetch(`/api/admin/webhooks/${id}`)
49
+ if (response.ok) setSelected(await response.json())
50
+ }
51
+
52
+ async function saveSelected() {
53
+ if (!selected) return
54
+ setSaving(true)
55
+ try {
56
+ const response = await fetch(`/api/admin/webhooks/${selected.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: selected.name, url: selected.url, events: selected.events, enabled: selected.enabled }) })
57
+ const data = await response.json()
58
+ if (!response.ok) throw new Error(data.error)
59
+ toast.success('Webhook saved.')
60
+ load()
61
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not save webhook') } finally { setSaving(false) }
62
+ }
63
+
64
+ async function remove(webhook: WebhookRow) {
65
+ if (!confirm(`Delete webhook "${webhook.name}"?`)) return
66
+ const response = await fetch(`/api/admin/webhooks/${webhook.id}`, { method: 'DELETE' })
67
+ if (response.ok) { toast.success('Webhook deleted.'); setSelected(null); load() } else toast.error('Could not delete webhook.')
68
+ }
69
+
70
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
71
+ <EditorHeader compact title="Webhooks" backHref="/admin/dashboard" backLabel="Dashboard" />
72
+ <div className="cms-editor-layout"><div className="cms-editor-form">
73
+ <section className="cms-section">
74
+ <div className="cms-section-heading"><p className="section-label">New webhook</p><p>Fires a signed POST request when the selected events happen. Verify deliveries with the <code>X-Webhook-Signature</code> header (HMAC-SHA256 of the body using the webhook&rsquo;s secret).</p></div>
75
+ {newSecret && <div className="cms-ai-note" role="status"><strong>{newSecret}</strong><p>This is the signing secret — copy it now, it won&rsquo;t be shown in full again outside this webhook&rsquo;s detail view.</p><Button variant="ghost" compact onClick={() => setNewSecret(null)}>Dismiss</Button></div>}
76
+ <div className="cms-two-columns">
77
+ <TextField label="Name" value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="e.g. Marketing CMS sync" />
78
+ <TextField label="URL" type="url" value={form.url} onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))} placeholder="https://example.com/webhooks/cms" />
79
+ </div>
80
+ {webhookEventGroups.map((group) => <div className="cms-checkbox-group-block" key={group.label}><p className="section-label">{group.label}</p><div className="cms-permission-grid">{group.events.map((event) => <label key={event} className="cms-permission-item"><input type="checkbox" checked={form.events.includes(event)} onChange={() => toggleEvent(event)} />{event}</label>)}</div></div>)}
81
+ <Button variant="positive" type="button" disabled={creating} onClick={() => void create()} icon="↗">{creating ? 'Creating…' : 'Create webhook'}</Button>
82
+ </section>
83
+ <section className="cms-section">
84
+ <div className="cms-section-heading"><p className="section-label">Existing webhooks</p></div>
85
+ {loading ? <ListingSkeleton view="table" columnCount={5} /> : <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Name</th><th>URL</th><th>Events</th><th>Status</th><th /></tr></thead><tbody>
86
+ {webhooks.map((webhook) => <tr key={webhook.id}>
87
+ <td><a className="cms-row-title" href="#" onClick={(event) => { event.preventDefault(); void openWebhook(webhook.id) }}><strong>{webhook.name}</strong></a></td>
88
+ <td><span className="cms-table-id">{webhook.url}</span></td>
89
+ <td>{webhook.events.length}</td>
90
+ <td>{webhook.enabled ? 'Enabled' : 'Disabled'}</td>
91
+ <td><Button variant="negative" compact type="button" onClick={() => void remove(webhook)}>Delete</Button></td>
92
+ </tr>)}
93
+ </tbody></table>{!webhooks.length && <p className="cms-empty-state">No webhooks yet.</p>}</div>}
94
+ </section>
95
+ {selected && <section className="cms-section">
96
+ <div className="cms-section-heading"><p className="section-label">Edit webhook</p><p>Secret: <code>{selected.secret}</code></p></div>
97
+ <div className="cms-two-columns">
98
+ <TextField label="Name" value={selected.name} onChange={(event) => setSelected({ ...selected, name: event.target.value })} />
99
+ <TextField label="URL" type="url" value={selected.url} onChange={(event) => setSelected({ ...selected, url: event.target.value })} />
100
+ </div>
101
+ <ToggleField label="Enabled" checked={selected.enabled} onChange={(checked) => setSelected({ ...selected, enabled: checked })} />
102
+ {webhookEventGroups.map((group) => <div className="cms-checkbox-group-block" key={group.label}><p className="section-label">{group.label}</p><div className="cms-permission-grid">{group.events.map((event) => <label key={event} className="cms-permission-item"><input type="checkbox" checked={selected.events.includes(event)} onChange={() => setSelected({ ...selected, events: selected.events.includes(event) ? selected.events.filter((item) => item !== event) : [...selected.events, event] })} />{event}</label>)}</div></div>)}
103
+ <Button variant="primary" type="button" disabled={saving} onClick={() => void saveSelected()} icon="↗">{saving ? 'Saving…' : 'Save changes'}</Button>
104
+ <div className="cms-section-heading"><p className="section-label">Recent deliveries</p></div>
105
+ {selected.deliveries.length ? <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Event</th><th>Status</th><th>When</th></tr></thead><tbody>
106
+ {selected.deliveries.map((delivery) => <tr key={delivery.id}>
107
+ <td>{delivery.event}</td>
108
+ <td>{delivery.success ? `OK (${delivery.statusCode})` : delivery.error ?? `Failed (${delivery.statusCode ?? '—'})`}</td>
109
+ <td>{formatDateTime(delivery.createdAt)}</td>
110
+ </tr>)}
111
+ </tbody></table></div> : <p className="cms-empty-state">No deliveries yet.</p>}
112
+ </section>}
113
+ </div></div>
114
+ </section></main>
115
+ }