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,102 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { formatDateTime } from '../format'
5
+ import { Button } from './Button'
6
+
7
+ export type ReviewStatus = 'draft' | 'in_review' | 'reviewed' | 'approved'
8
+ const reviewStatusLabels: Record<ReviewStatus, string> = { draft: 'Draft', in_review: 'In review', reviewed: 'Reviewed', approved: 'Approved' }
9
+
10
+ type Props = {
11
+ published: boolean
12
+ dirty: boolean
13
+ saving: 'draft' | 'publish' | 'unpublish' | 'schedule' | 'unschedule' | 'workflow' | null
14
+ message: string
15
+ publishedAt?: string | null
16
+ scheduledAt?: string | null
17
+ canSchedule?: boolean
18
+ onSaveDraft: () => void
19
+ onPublish: () => void
20
+ onUnpublish?: () => void
21
+ onSchedule?: () => void
22
+ onUnschedule?: () => void
23
+ onTrash?: () => void
24
+ reviewStatus: ReviewStatus
25
+ requiresApproval?: boolean
26
+ reviewerName?: string | null
27
+ canSubmitReview?: boolean
28
+ canReview?: boolean
29
+ canApprove?: boolean
30
+ reviewers?: Array<{ id: string; name: string }>
31
+ onSubmitReview?: (reviewerId: string) => void
32
+ onReviewDecision?: (decision: 'approve' | 'request_changes') => void
33
+ onApprove?: () => void
34
+ }
35
+
36
+ export function PublishingPanel({ published, dirty, saving, message, publishedAt, scheduledAt, canSchedule, onSaveDraft, onPublish, onUnpublish, onSchedule, onUnschedule, onTrash, reviewStatus, requiresApproval = true, reviewerName, canSubmitReview, canReview, canApprove, reviewers, onSubmitReview, onReviewDecision, onApprove }: Props) {
37
+ const approvalEnabled = requiresApproval
38
+ const [pickedReviewer, setPickedReviewer] = useState('')
39
+ const [menuOpen, setMenuOpen] = useState(false)
40
+ const menuWrap = useRef<HTMLDivElement>(null)
41
+ const isScheduled = !published && Boolean(scheduledAt)
42
+ const isOverdue = isScheduled && Boolean(scheduledAt) && new Date(scheduledAt!) < new Date()
43
+ const isApproved = reviewStatus === 'approved'
44
+ const isSettled = published && !dirty
45
+ const publishDisabled = saving !== null || isSettled || (!published && approvalEnabled && !isApproved)
46
+ const publishLabel = saving === 'publish' ? 'Publishing…' : published ? 'Publish update' : 'Publish'
47
+ const statusLabel = published ? 'Published' : isScheduled ? 'Scheduled' : approvalEnabled ? reviewStatusLabels[reviewStatus] : 'Draft'
48
+
49
+ useEffect(() => {
50
+ function closeMenu(event: PointerEvent) { if (!menuWrap.current?.contains(event.target as Node)) setMenuOpen(false) }
51
+ function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') setMenuOpen(false) }
52
+ document.addEventListener('pointerdown', closeMenu)
53
+ document.addEventListener('keydown', closeOnEscape)
54
+ return () => { document.removeEventListener('pointerdown', closeMenu); document.removeEventListener('keydown', closeOnEscape) }
55
+ }, [])
56
+
57
+ return <div className="cms-editor-sidebar-stack">
58
+ <aside className="cms-editor-sidebar">
59
+ <div className="cms-publishing-heading"><span>Publishing</span>{(published && onUnpublish) || onTrash ? <div className="cms-publishing-menu-wrap" ref={menuWrap}><Button variant="secondary" iconOnly className="cms-publishing-menu-trigger" aria-label="More publishing actions" aria-expanded={menuOpen} onClick={() => setMenuOpen((open) => !open)} icon={<span aria-hidden="true">•••</span>} />{menuOpen && <div className="cms-publishing-menu" role="menu">
60
+ {published && onUnpublish && <Button variant="ghost" className="cms-menu-item-caution" role="menuitem" disabled={saving !== null} onClick={() => { setMenuOpen(false); onUnpublish() }} icon={<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h12M13 7l5 5-5 5M19 5v14" /></svg>} iconPosition="before">{saving === 'unpublish' ? 'Unpublishing…' : 'Unpublish'}</Button>}
61
+ {onTrash && <Button variant="negative" role="menuitem" onClick={() => { setMenuOpen(false); onTrash() }} icon={<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6m4-6v6M9 7l1-2h4l1 2m-9 0 1 13h10l1-13" /></svg>} iconPosition="before">Delete</Button>}
62
+ </div>}</div> : null}</div>
63
+ <div className="cms-publishing-summary">
64
+ <span className={`cms-status ${published ? 'is-published' : ''} ${isScheduled ? 'is-scheduled' : ''}`}>{statusLabel}</span>
65
+ {dirty && <span className="cms-unsaved-status">Unsaved changes</span>}
66
+ </div>
67
+ {isSettled && <p className="cms-publishing-published-note">{publishedAt ? <>Published since {formatDateTime(publishedAt)}.</> : <>Published.</>}{onUnpublish && <> Use the menu above to unpublish.</>}</p>}
68
+ {isScheduled && <p className="cms-scheduled-note">{isOverdue ? <>Was due {formatDateTime(scheduledAt)} — waiting for the scheduler to run.</> : <>Publishes automatically on {formatDateTime(scheduledAt)}.</>} <Button variant="ghost" compact className="cms-inline-unschedule" onClick={onUnschedule}>Unschedule</Button></p>}
69
+ <p aria-live="polite">{message}</p>
70
+
71
+ {approvalEnabled && <div className="cms-workflow-box">
72
+ {reviewStatus === 'draft' && canSubmitReview && <div className="cms-workflow-step">
73
+ <select value={pickedReviewer} onChange={(event) => setPickedReviewer(event.target.value)} aria-label="Choose a reviewer">
74
+ <option value="">Choose a reviewer…</option>
75
+ {reviewers?.map((reviewer) => <option key={reviewer.id} value={reviewer.id}>{reviewer.name}</option>)}
76
+ </select>
77
+ <Button variant="secondary" disabled={!pickedReviewer || saving !== null} onClick={() => onSubmitReview?.(pickedReviewer)}>{saving === 'workflow' ? 'Submitting…' : 'Submit for review'}</Button>
78
+ </div>}
79
+ {reviewStatus === 'in_review' && <p className="cms-workflow-note">Awaiting review{reviewerName ? ` from ${reviewerName}` : ''}.</p>}
80
+ {reviewStatus === 'in_review' && canReview && <div className="cms-workflow-step">
81
+ <Button variant="positive" disabled={saving !== null} onClick={() => onReviewDecision?.('approve')}>{saving === 'workflow' ? 'Saving…' : 'Pass review'}</Button>
82
+ <Button variant="negative" onClick={() => onReviewDecision?.('request_changes')}>Request changes</Button>
83
+ </div>}
84
+ {reviewStatus === 'reviewed' && <p className="cms-workflow-note">Passed review — awaiting approval to publish.</p>}
85
+ {reviewStatus === 'reviewed' && canApprove && <div className="cms-workflow-step">
86
+ <Button variant="positive" disabled={saving !== null} onClick={onApprove}>{saving === 'workflow' ? 'Approving…' : 'Approve'}</Button>
87
+ </div>}
88
+ {reviewStatus === 'approved' && !published && <p className="cms-workflow-note">Approved — ready to publish or schedule.</p>}
89
+ </div>}
90
+
91
+ {!isSettled && <>
92
+ <div className="cms-publishing-actions">
93
+ <Button variant="secondary" disabled={!dirty || saving !== null} onClick={onSaveDraft}>{saving === 'draft' ? 'Saving…' : 'Save draft'}</Button>
94
+ <Button variant="positive" disabled={publishDisabled} onClick={onPublish} icon={<span>↗</span>}>{publishLabel}</Button>
95
+ </div>
96
+ {!published && approvalEnabled && !isApproved && <small className="cms-schedule-hint">Needs approval before this can be published.</small>}
97
+ {onSchedule && <Button variant="secondary" disabled={saving !== null || !canSchedule || (approvalEnabled && !isApproved)} onClick={onSchedule}>{saving === 'schedule' ? 'Scheduling…' : isScheduled ? 'Update schedule' : 'Schedule'}</Button>}
98
+ {onSchedule && !canSchedule && !isScheduled && (!approvalEnabled || isApproved) && <small className="cms-schedule-hint">Pick a scheduled date below to enable this.</small>}
99
+ </>}
100
+ </aside>
101
+ </div>
102
+ }
@@ -0,0 +1,24 @@
1
+ type Props = {
2
+ createdByName?: string | null
3
+ updatedByName?: string | null
4
+ approvedByName?: string | null
5
+ ownerId?: string | null
6
+ owners?: Array<{ id: string; name: string }>
7
+ onOwnerChange?: (ownerId: string) => void
8
+ }
9
+
10
+ /** Who touched this record — rendered inside the "record" field group's own card in the main
11
+ * content form, not as a separate floating panel (that used to duplicate the group's own
12
+ * Created/Last updated fields with its own "Timeline" section — this only ever shows what
13
+ * isn't already on the form: the people, not the dates). */
14
+ export function RecordMeta({ createdByName, updatedByName, approvedByName, ownerId, owners, onOwnerChange }: Props) {
15
+ return <section className="cms-record-section cms-record-meta">
16
+ <div className="cms-record-section-heading"><strong>People</strong></div>
17
+ {owners && onOwnerChange && <label className="cms-audit-owner"><span>Current owner</span><select value={ownerId ?? ''} onChange={(event) => onOwnerChange(event.target.value)}><option value="">Unassigned</option>{owners.map((owner) => <option key={owner.id} value={owner.id}>{owner.name}</option>)}</select></label>}
18
+ <div className="cms-meta-list">
19
+ <div><span>Created by</span><strong>{createdByName || 'Not recorded'}</strong></div>
20
+ <div><span>Last updated by</span><strong>{updatedByName || 'Not recorded'}</strong></div>
21
+ {approvedByName !== undefined && <div><span>Approved by</span><strong>{approvedByName || 'Not approved'}</strong></div>}
22
+ </div>
23
+ </section>
24
+ }
@@ -0,0 +1,103 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { useToast } from './Toast'
7
+ import { formatDateTime } from '../format'
8
+ import { CardListSkeleton } from './EditorSkeleton'
9
+ import { Button } from './Button'
10
+ import type { CollectionSlug } from '../collections/types'
11
+
12
+ type RawItem = { id: string; title: string; reviewStatus: string; reviewerId: string | null; updatedAt: string }
13
+ type QueueItem = RawItem & { type: CollectionSlug }
14
+ type UserOption = { id: string; name: string }
15
+ // `labelSingular`/`titleField` per review-enabled collection — resolved server-side (the
16
+ // registry is never populated on the client, `defineKiloConfig()` is server-only) and passed
17
+ // down as a prop, same pattern as `CollectionRecordEditor`'s `config` prop.
18
+ export type ReviewCollectionMeta = Record<CollectionSlug, { labelSingular: string; titleField: string }>
19
+
20
+ const statusLabels: Record<string, string> = { in_review: 'In review', reviewed: 'Reviewed' }
21
+ const hrefForType = (type: CollectionSlug, id: string) => `/admin/collections/${type}/${id}`
22
+ const endpointForType = (type: CollectionSlug) => `/api/admin/collections/${type}`
23
+
24
+ export function ReviewQueueEditor({ userName, currentUserId, canReview, canApprove, isAdmin, meta }: { userName: string; currentUserId: string; canReview: Record<CollectionSlug, boolean>; canApprove: Record<CollectionSlug, boolean>; isAdmin: boolean; meta: ReviewCollectionMeta }) {
25
+ const toast = useToast()
26
+ const labelForType = (type: CollectionSlug) => meta[type]?.labelSingular ?? type
27
+ const reviewSlugs = Object.keys(meta) as CollectionSlug[]
28
+ const [items, setItems] = useState<QueueItem[]>([])
29
+ const [users, setUsers] = useState<UserOption[]>([])
30
+ const [loading, setLoading] = useState(true)
31
+ const [busyId, setBusyId] = useState<string | null>(null)
32
+
33
+ async function load() {
34
+ setLoading(true)
35
+ try {
36
+ const [usersData, ...typeData] = await Promise.all([
37
+ fetch('/api/admin/users').then((response) => response.ok ? response.json() : []),
38
+ ...reviewSlugs.map((slug) => fetch(`/api/admin/collections/${slug}?pageSize=all`).then((response) => response.ok ? response.json() : { items: [] })),
39
+ ])
40
+ const merged: QueueItem[] = typeData.flatMap((data, index) =>
41
+ ((data as { items: Array<Record<string, unknown>> }).items ?? []).map((item) => ({
42
+ id: String(item.id),
43
+ title: String(item[meta[reviewSlugs[index]]?.titleField ?? 'id'] ?? ''),
44
+ reviewStatus: String(item.reviewStatus ?? 'draft'),
45
+ reviewerId: typeof item.reviewerId === 'string' ? item.reviewerId : null,
46
+ updatedAt: String(item.updatedAt ?? ''),
47
+ type: reviewSlugs[index],
48
+ })),
49
+ ).filter((item) => item.reviewStatus === 'in_review' || item.reviewStatus === 'reviewed')
50
+ merged.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
51
+ setItems(merged)
52
+ setUsers(usersData as UserOption[])
53
+ } finally {
54
+ setLoading(false)
55
+ }
56
+ }
57
+ useEffect(() => { load() }, [])
58
+
59
+ function reviewerName(id: string | null) { return users.find((user) => user.id === id)?.name ?? '—' }
60
+
61
+ async function reviewDecision(item: QueueItem, decision: 'approve' | 'request_changes') {
62
+ setBusyId(item.id)
63
+ try {
64
+ const response = await fetch(`${endpointForType(item.type)}/${item.id}/review`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision }) })
65
+ const data = await response.json()
66
+ if (!response.ok) throw new Error(data?.error || 'Could not update review')
67
+ toast.success(decision === 'approve' ? 'Marked as reviewed.' : 'Sent back for changes.')
68
+ load()
69
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not update review') } finally { setBusyId(null) }
70
+ }
71
+
72
+ async function approve(item: QueueItem) {
73
+ setBusyId(item.id)
74
+ try {
75
+ const response = await fetch(`${endpointForType(item.type)}/${item.id}/approve`, { method: 'PATCH' })
76
+ const data = await response.json()
77
+ if (!response.ok) throw new Error(data?.error || 'Could not approve')
78
+ toast.success('Approved — ready to publish.')
79
+ load()
80
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not approve') } finally { setBusyId(null) }
81
+ }
82
+
83
+ return <main className="cms-dashboard"><CmsSidebar active="review-queue" userName={userName} /><section className="cms-content">
84
+ <EditorHeader compact title="Review Queue" meta={`${items.length} awaiting action`} />
85
+ {loading ? <CardListSkeleton /> : <div className="cms-schedule-list">
86
+ {!items.length && <p className="cms-empty-state">Nothing awaiting review or approval.</p>}
87
+ {items.map((item) => {
88
+ const canActuallyReview = canReview[item.type] && item.reviewStatus === 'in_review' && (isAdmin || item.reviewerId === currentUserId)
89
+ const canActuallyApprove = canApprove[item.type] && item.reviewStatus === 'reviewed'
90
+ return <div className="cms-schedule-row" key={`${item.type}-${item.id}`}>
91
+ <span className="cms-schedule-time">{labelForType(item.type)}<span className="cms-status is-scheduled">{statusLabels[item.reviewStatus]}</span></span>
92
+ <a className="cms-schedule-title" href={hrefForType(item.type, item.id)}>{item.title || 'Untitled'}</a>
93
+ <span className="cms-schedule-category">Reviewer: {reviewerName(item.reviewerId)}<br />Updated {formatDateTime(item.updatedAt)}</span>
94
+ <div className="cms-schedule-actions">
95
+ {canActuallyReview && <Button variant="positive" compact disabled={busyId === item.id} onClick={() => void reviewDecision(item, 'approve')}>Pass review</Button>}
96
+ {canActuallyReview && <Button variant="negative" compact disabled={busyId === item.id} onClick={() => void reviewDecision(item, 'request_changes')}>Request changes</Button>}
97
+ {canActuallyApprove && <Button variant="positive" compact disabled={busyId === item.id} onClick={() => void approve(item)}>Approve</Button>}
98
+ </div>
99
+ </div>
100
+ })}
101
+ </div>}
102
+ </section></main>
103
+ }
@@ -0,0 +1,49 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import { useEditor, EditorContent } from '@tiptap/react'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import Link from '@tiptap/extension-link'
7
+ import Placeholder from '@tiptap/extension-placeholder'
8
+ import Image from '@tiptap/extension-image'
9
+ import { TableKit } from '@tiptap/extension-table'
10
+ import { Field } from './Field'
11
+ import { emptyRichText, type RichTextDocument } from '../richtext'
12
+ import { ImageUploader } from './ImageUploader'
13
+ import { AiWritingAssistant } from './AiWritingAssistant'
14
+
15
+ type Props = { label: string; hint?: string; required?: boolean; error?: string; value: RichTextDocument; onChange: (value: RichTextDocument) => void }
16
+
17
+ function ToolButton({ active, title, onClick, children, className = '' }: { active?: boolean; title: string; onClick: () => void; children: ReactNode; className?: string }) {
18
+ return <button type="button" title={title} aria-label={title} className={`${active ? 'active ' : ''}${className}`.trim()} onMouseDown={(event) => event.preventDefault()} onClick={onClick}>{children}</button>
19
+ }
20
+
21
+ function AiIcon() {
22
+ return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.8 13.7 9l6.2 1.7-6.2 1.7-1.7 6.2-1.7-6.2-6.2-1.7L10.3 9 12 2.8Zm6.6 12.3.7 2.5 2.5.7-2.5.7-.7 2.5-.7-2.5-2.5-.7 2.5-.7.7-2.5Z" /></svg>
23
+ }
24
+
25
+ function TableIcon() {
26
+ return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v16H4V4Zm5 2H6v4h3V6Zm2 0v4h7V6h-7Zm-5 6v6h3v-6H6Zm5 0v6h7v-6h-7Z" /></svg>
27
+ }
28
+
29
+ export function RichTextEditor({ label, hint = 'Use the toolbar to format your content', required, error, value, onChange }: Props) {
30
+ const [aiOpen, setAiOpen] = useState(false)
31
+ const [schemaEpoch, setSchemaEpoch] = useState(0)
32
+ const tableInstanceRecovered = useRef(false)
33
+ const editor = useEditor({
34
+ immediatelyRender: false,
35
+ extensions: [StarterKit.configure({ heading: { levels: [2, 3, 4] } }), Link.configure({ openOnClick: false, autolink: true, defaultProtocol: 'https' }), Image.configure({ inline: false, allowBase64: false }), TableKit.configure({ table: { resizable: true, HTMLAttributes: { class: 'cms-editor-table' } } }), Placeholder.configure({ placeholder: 'Write your story…' })],
36
+ content: value || emptyRichText,
37
+ onUpdate: ({ editor: current }) => onChange(current.getJSON() as RichTextDocument),
38
+ editorProps: { attributes: { class: 'cms-richtext-content', 'aria-label': label } },
39
+ }, [schemaEpoch])
40
+ useEffect(() => {
41
+ if (!editor || typeof editor.commands.insertTable === 'function' || tableInstanceRecovered.current) return
42
+ tableInstanceRecovered.current = true
43
+ setSchemaEpoch((current) => current + 1)
44
+ }, [editor])
45
+ useEffect(() => { if (!editor) return; const next = JSON.stringify(value || emptyRichText); if (JSON.stringify(editor.getJSON()) !== next) editor.commands.setContent(value || emptyRichText, { emitUpdate: false }) }, [editor, value])
46
+ function setLink() { if (!editor) return; const previous = editor.getAttributes('link').href as string | undefined; const href = window.prompt('Paste a URL', previous || 'https://'); if (href === null) return; if (!href.trim()) { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return } editor.chain().focus().extendMarkRange('link').setLink({ href }).run() }
47
+ function insertTable() { const chain = editor?.chain().focus(); if (!chain || typeof chain.insertTable !== 'function') { setSchemaEpoch((current) => current + 1); return } chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }
48
+ return <><Field label={label} hint={hint} required={required} error={error}><div className="cms-richtext"><div className="cms-richtext-toolbar" role="toolbar" aria-label={`${label} formatting`}><ToolButton title="Paragraph" active={editor?.isActive('paragraph')} onClick={() => editor?.chain().focus().setParagraph().run()}>P</ToolButton><ToolButton title="Heading 2" active={editor?.isActive('heading', { level: 2 })} onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolButton><ToolButton title="Heading 3" active={editor?.isActive('heading', { level: 3 })} onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolButton><i /><ToolButton title="Bold" active={editor?.isActive('bold')} onClick={() => editor?.chain().focus().toggleBold().run()}><b>B</b></ToolButton><ToolButton title="Italic" active={editor?.isActive('italic')} onClick={() => editor?.chain().focus().toggleItalic().run()}><em>I</em></ToolButton><ToolButton title="Strike through" active={editor?.isActive('strike')} onClick={() => editor?.chain().focus().toggleStrike().run()}><s>S</s></ToolButton><ToolButton title="Inline code" active={editor?.isActive('code')} onClick={() => editor?.chain().focus().toggleCode().run()}>&lt;/&gt;</ToolButton><i /><ToolButton title="Bullet list" active={editor?.isActive('bulletList')} onClick={() => editor?.chain().focus().toggleBulletList().run()}>•≡</ToolButton><ToolButton title="Numbered list" active={editor?.isActive('orderedList')} onClick={() => editor?.chain().focus().toggleOrderedList().run()}>1≡</ToolButton><ToolButton title="Quote" active={editor?.isActive('blockquote')} onClick={() => editor?.chain().focus().toggleBlockquote().run()}>❝</ToolButton><i /><ToolButton title="Add link" active={editor?.isActive('link')} onClick={setLink}>↗</ToolButton><ImageUploader compact title="Insert image from Media Library or upload" label="▧" value="" onChange={(url) => editor?.chain().focus().setImage({ src: url }).run()} /><ToolButton title="Insert table (3 × 3)" onClick={insertTable}><TableIcon /></ToolButton>{editor?.isActive('table') && <><i /><ToolButton title="Add row below" onClick={() => editor.chain().focus().addRowAfter().run()}>+R</ToolButton><ToolButton title="Remove current row" onClick={() => editor.chain().focus().deleteRow().run()}>−R</ToolButton><ToolButton title="Add column after" onClick={() => editor.chain().focus().addColumnAfter().run()}>+C</ToolButton><ToolButton title="Remove current column" onClick={() => editor.chain().focus().deleteColumn().run()}>−C</ToolButton><ToolButton title="Toggle header row" active={editor.isActive('tableHeader')} onClick={() => editor.chain().focus().toggleHeaderRow().run()}>TH</ToolButton><ToolButton title="Merge or split selected cells" onClick={() => editor.chain().focus().mergeOrSplit().run()}>⊞</ToolButton><ToolButton title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>×</ToolButton></>}<ToolButton className="cms-richtext-ai" title="AI writing assistant" onClick={() => setAiOpen(true)}><AiIcon />AI</ToolButton><i /><ToolButton title="Undo" onClick={() => editor?.chain().focus().undo().run()}>↶</ToolButton><ToolButton title="Redo" onClick={() => editor?.chain().focus().redo().run()}>↷</ToolButton></div><EditorContent editor={editor} /></div></Field><AiWritingAssistant editor={editor} open={aiOpen} onClose={() => setAiOpen(false)} /></>
49
+ }
@@ -0,0 +1,121 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { CmsSidebar } from './CmsSidebar'
6
+ import { EditorHeader } from './EditorHeader'
7
+ import { useToast } from './Toast'
8
+ import { TextField } from './Field'
9
+ import { EditorSkeleton } from './EditorSkeleton'
10
+ import { Button } from './Button'
11
+ import {
12
+ actionLabels, contentTypeDefs, contentTypeKeys, emptyRolePermissions, systemPermissionLabels, systemPermissions,
13
+ type ActionKey, type ContentTypeKey, type StoredRolePermissions, type SystemPermission,
14
+ } from '../content-types'
15
+
16
+ type Role = { id: string; name: string; permissions: StoredRolePermissions; isSystem: boolean }
17
+ const emptyRole = (): Role => ({ id: '', name: '', permissions: emptyRolePermissions, isSystem: false })
18
+
19
+ function SelectAll({ label, checked, partial, disabled, onChange }: { label: string; checked: boolean; partial: boolean; disabled: boolean; onChange: (checked: boolean) => void }) {
20
+ const input = useRef<HTMLInputElement>(null)
21
+ useEffect(() => { if (input.current) input.current.indeterminate = partial && !checked }, [checked, partial])
22
+ return <label className="cms-permission-select-all"><input ref={input} type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />{label}</label>
23
+ }
24
+
25
+ export function RoleEditor({ id, userName }: { id: string; userName: string }) {
26
+ const router = useRouter()
27
+ const toast = useToast()
28
+ const isNew = id === 'new'
29
+ const [role, setRole] = useState<Role | null>(isNew ? emptyRole() : null)
30
+ const [loading, setLoading] = useState(!isNew)
31
+ const [saving, setSaving] = useState(false)
32
+
33
+ useEffect(() => {
34
+ if (isNew) return
35
+ fetch(`/api/admin/roles/${id}`).then((response) => response.ok ? response.json() : null).then((data: Role | null) => { if (data) setRole({ ...data, permissions: { ...emptyRolePermissions, ...data.permissions } }) }).finally(() => setLoading(false))
36
+ }, [id, isNew])
37
+
38
+ function toggleAction(type: ContentTypeKey, action: ActionKey) {
39
+ setRole((current) => {
40
+ if (!current) return current
41
+ const contentTypes = { ...current.permissions.contentTypes }
42
+ const existing = contentTypes[type] ?? { actions: [], fields: [] }
43
+ const hasAction = existing.actions.includes(action)
44
+ const nextActions = hasAction ? existing.actions.filter((item) => item !== action) : [...existing.actions, action]
45
+ const gainingFieldAccess = !hasAction && (action === 'update' || action === 'create') && existing.fields.length === 0
46
+ const nextFields = gainingFieldAccess ? contentTypeDefs[type].fields.map((field) => field.key) : existing.fields
47
+ contentTypes[type] = { actions: nextActions, fields: nextFields }
48
+ return { ...current, permissions: { ...current.permissions, contentTypes } }
49
+ })
50
+ }
51
+
52
+ function toggleAllActions(type: ContentTypeKey, checked: boolean) {
53
+ setRole((current) => current && { ...current, permissions: { ...current.permissions, contentTypes: { ...current.permissions.contentTypes, [type]: { ...(current.permissions.contentTypes[type] ?? { fields: [] }), actions: checked ? [...contentTypeDefs[type].actions] : [] } } } })
54
+ }
55
+
56
+ function toggleField(type: ContentTypeKey, fieldKey: string) {
57
+ setRole((current) => {
58
+ if (!current) return current
59
+ const contentTypes = { ...current.permissions.contentTypes }
60
+ const existing = contentTypes[type] ?? { actions: [], fields: [] }
61
+ const nextFields = existing.fields.includes(fieldKey) ? existing.fields.filter((item) => item !== fieldKey) : [...existing.fields, fieldKey]
62
+ contentTypes[type] = { ...existing, fields: nextFields }
63
+ return { ...current, permissions: { ...current.permissions, contentTypes } }
64
+ })
65
+ }
66
+
67
+ function toggleAllFields(type: ContentTypeKey, checked: boolean) {
68
+ setRole((current) => current && { ...current, permissions: { ...current.permissions, contentTypes: { ...current.permissions.contentTypes, [type]: { ...(current.permissions.contentTypes[type] ?? { actions: [] }), fields: checked ? contentTypeDefs[type].fields.map((field) => field.key) : [] } } } })
69
+ }
70
+
71
+ function toggleSystem(permission: SystemPermission) {
72
+ setRole((current) => current && { ...current, permissions: { ...current.permissions, system: current.permissions.system.includes(permission) ? current.permissions.system.filter((item) => item !== permission) : [...current.permissions.system, permission] } })
73
+ }
74
+
75
+ function toggleAllSystem(checked: boolean) {
76
+ setRole((current) => current && { ...current, permissions: { ...current.permissions, system: checked ? [...systemPermissions] : [] } })
77
+ }
78
+
79
+ async function save() {
80
+ if (!role) return
81
+ if (!role.name.trim()) { toast.error('Give the role a display name.'); return }
82
+ if (isNew && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(role.id)) { toast.error('Use a lowercase role ID with letters, numbers, and hyphens.'); return }
83
+ setSaving(true)
84
+ try {
85
+ const response = await fetch(isNew ? '/api/admin/roles' : `/api/admin/roles/${id}`, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(isNew ? { id: role.id, name: role.name, permissions: role.permissions } : { name: role.name, permissions: role.permissions }) })
86
+ const data = await response.json()
87
+ if (!response.ok) throw new Error(data.error)
88
+ toast.success(isNew ? 'Role created.' : 'Permissions saved.')
89
+ if (isNew) router.replace(`/admin/roles/${data.id}`)
90
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not save role') } finally { setSaving(false) }
91
+ }
92
+
93
+ if (loading) return <main className="cms-dashboard"><CmsSidebar active="roles" userName={userName} /><section className="cms-content"><EditorSkeleton sections={2} /></section></main>
94
+ if (!role) { router.push('/admin/roles'); return null }
95
+
96
+ const disabled = role.isSystem
97
+
98
+ return <main className="cms-dashboard"><CmsSidebar active="roles" userName={userName} /><section className="cms-content">
99
+ <EditorHeader compact title={isNew ? 'New role' : role.name} backHref="/admin/roles" backLabel="All roles" actions={!disabled ? <Button variant="positive" type="button" onClick={() => void save()} disabled={saving} icon="↗">{saving ? 'Saving…' : isNew ? 'Create role' : 'Save permissions'}</Button> : undefined} />
100
+ {!disabled && <section className="cms-section cms-role-details"><div className="cms-section-heading"><p className="section-label">Role details</p><p>{isNew ? 'The ID is used internally and cannot be changed later.' : 'Update the display name for this role.'}</p></div><div className="cms-two-columns"><TextField label="Role ID" required value={role.id} disabled={!isNew} onChange={(event) => setRole((current) => current && { ...current, id: event.target.value })} hint="lowercase-with-hyphens" /><TextField label="Display name" required value={role.name} onChange={(event) => setRole((current) => current && { ...current, name: event.target.value })} /></div></section>}
101
+ {disabled && <p className="cms-ai-status">This is the built-in Administrator role — it always has every permission and can&rsquo;t be changed.</p>}
102
+ <div className="cms-roles-grid">
103
+ {contentTypeKeys.map((key) => {
104
+ const def = contentTypeDefs[key]
105
+ const current = role.permissions.contentTypes[key] ?? { actions: [], fields: [] }
106
+ const canEditFields = disabled || current.actions.includes('update') || current.actions.includes('create')
107
+ return <section className="cms-section cms-role-card" key={key}>
108
+ <div className="cms-section-heading"><p className="section-label">{def.label}</p><p>{def.kind === 'single' ? 'Single type' : 'Collection'}</p></div>
109
+ <div className="cms-permission-toolbar"><span>Actions</span><SelectAll label="Select all" checked={disabled || def.actions.every((action) => current.actions.includes(action))} partial={!disabled && current.actions.some((action) => current.actions.includes(action)) && !def.actions.every((action) => current.actions.includes(action))} disabled={disabled} onChange={(checked) => toggleAllActions(key, checked)} /></div>
110
+ <div className="cms-permission-grid">{def.actions.map((action) => <label key={action} className="cms-permission-item"><input type="checkbox" checked={disabled || current.actions.includes(action)} disabled={disabled} onChange={() => toggleAction(key, action)} />{actionLabels[action]}</label>)}</div>
111
+ {canEditFields && <details className="cms-publishing-details" open><summary>Fields</summary><div className="cms-permission-toolbar"><span>Field access</span><SelectAll label="Select all" checked={disabled || def.fields.every((field) => current.fields.includes(field.key))} partial={!disabled && def.fields.some((field) => current.fields.includes(field.key)) && !def.fields.every((field) => current.fields.includes(field.key))} disabled={disabled} onChange={(checked) => toggleAllFields(key, checked)} /></div><div className="cms-permission-grid">{def.fields.map((field) => <label key={field.key} className="cms-permission-item"><input type="checkbox" checked={disabled || current.fields.includes(field.key)} disabled={disabled} onChange={() => toggleField(key, field.key)} />{field.label}</label>)}</div></details>}
112
+ </section>
113
+ })}
114
+ <section className="cms-section cms-role-card">
115
+ <div className="cms-section-heading"><p className="section-label">System</p><p>Not tied to a specific content type</p></div>
116
+ <div className="cms-permission-toolbar"><span>Permissions</span><SelectAll label="Select all" checked={disabled || systemPermissions.every((permission) => role.permissions.system.includes(permission))} partial={!disabled && systemPermissions.some((permission) => role.permissions.system.includes(permission)) && !systemPermissions.every((permission) => role.permissions.system.includes(permission))} disabled={disabled} onChange={toggleAllSystem} /></div>
117
+ <div className="cms-permission-grid">{systemPermissions.map((permission) => <label key={permission} className="cms-permission-item"><input type="checkbox" checked={disabled || role.permissions.system.includes(permission)} disabled={disabled} onChange={() => toggleSystem(permission)} />{systemPermissionLabels[permission]}</label>)}</div>
118
+ </section>
119
+ </div>
120
+ </section></main>
121
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { useToast } from './Toast'
7
+ import { ListingSkeleton } from './ListingView'
8
+ import { Button } from './Button'
9
+
10
+ type RoleRow = { id: string; name: string; isSystem: boolean; userCount: number }
11
+
12
+ export function RolesEditor({ userName }: { userName: string }) {
13
+ const toast = useToast()
14
+ const [roles, setRoles] = useState<RoleRow[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [q, setQ] = useState('')
17
+
18
+ function load() { setLoading(true); fetch('/api/admin/roles').then((response) => response.ok ? response.json() : []).then(setRoles).finally(() => setLoading(false)) }
19
+ useEffect(() => { load() }, [])
20
+
21
+ async function removeRole(role: RoleRow) {
22
+ if (!confirm(`Delete role "${role.name}"? Any user with this role keeps the value until reassigned.`)) return
23
+ const response = await fetch(`/api/admin/roles/${role.id}`, { method: 'DELETE' })
24
+ if (response.ok) { toast.success('Role deleted.'); load() } else toast.error('Could not delete role.')
25
+ }
26
+
27
+ const filtered = q ? roles.filter((role) => role.name.toLowerCase().includes(q.toLowerCase())) : roles
28
+
29
+ return <main className="cms-dashboard"><CmsSidebar active="roles" userName={userName} /><section className="cms-content">
30
+ <EditorHeader compact title="Roles" meta={`${roles.length} roles`} actions={<Button variant="positive" href="/admin/roles/new" icon="↗">Add role</Button>} />
31
+ <div className="cms-filter-bar">
32
+ <input className="cms-search-input" value={q} onChange={(event) => setQ(event.target.value)} placeholder="Search roles…" />
33
+ </div>
34
+ {loading ? <ListingSkeleton view="table" columnCount={3} /> : <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Role</th><th>Users</th><th /></tr></thead><tbody>
35
+ {filtered.map((role) => <tr key={role.id}>
36
+ <td><a className="cms-row-title" href={`/admin/roles/${role.id}`}><strong>{role.name}</strong></a>{role.isSystem && <span className="cms-table-id"> Built-in</span>}</td>
37
+ <td>{role.userCount}</td>
38
+ <td>{!role.isSystem && <Button variant="negative" compact type="button" onClick={() => void removeRole(role)}>Delete</Button>}</td>
39
+ </tr>)}
40
+ </tbody></table>{!filtered.length && <p className="cms-empty-state">No roles match your search.</p>}</div>}
41
+ </section></main>
42
+ }
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import { Portal } from './Portal'
5
+ import { Button } from './Button'
6
+
7
+ export type ActionIconName = 'edit' | 'duplicate' | 'preview' | 'trash' | 'restore' | 'publish' | 'unpublish'
8
+ type MenuItem = { label: string; icon?: ActionIconName; onClick?: () => void; href?: string; newTab?: boolean; danger?: boolean }
9
+
10
+ export function ActionIcon({ name }: { name: ActionIconName }) {
11
+ const paths: Record<ActionIconName, ReactNode> = {
12
+ edit: <><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" /></>,
13
+ duplicate: <><rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" /></>,
14
+ preview: <><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></>,
15
+ trash: <><path d="M4 7h16" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" /><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" /></>,
16
+ restore: <><path d="M3 10a9 9 0 1 1 2.6 6.4" /><path d="M3 4v6h6" /></>,
17
+ publish: <><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9" /></>,
18
+ unpublish: <><circle cx="12" cy="12" r="9" /><path d="M8 8l8 8" /></>,
19
+ }
20
+ return <svg className="cms-action-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">{paths[name]}</svg>
21
+ }
22
+
23
+ function MoreIcon() {
24
+ return <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="12" cy="19" r="1.6" /></svg>
25
+ }
26
+
27
+ export function RowActionsMenu({ items }: { items: MenuItem[] }) {
28
+ const [open, setOpen] = useState(false)
29
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
30
+ const triggerRef = useRef<HTMLButtonElement>(null)
31
+ const menuRef = useRef<HTMLDivElement>(null)
32
+
33
+ useEffect(() => {
34
+ if (!open) return
35
+ function handleOutside(event: MouseEvent) {
36
+ const target = event.target as Node
37
+ if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
38
+ setOpen(false)
39
+ }
40
+ function handleKey(event: KeyboardEvent) { if (event.key === 'Escape') setOpen(false) }
41
+ document.addEventListener('mousedown', handleOutside)
42
+ document.addEventListener('keydown', handleKey)
43
+ return () => { document.removeEventListener('mousedown', handleOutside); document.removeEventListener('keydown', handleKey) }
44
+ }, [open])
45
+
46
+ function toggle() {
47
+ if (!open && triggerRef.current) {
48
+ const rect = triggerRef.current.getBoundingClientRect()
49
+ const dropdownHeight = items.length * 40 + 12
50
+ const openUpward = rect.bottom + dropdownHeight > window.innerHeight
51
+ setPosition({ top: openUpward ? rect.top - dropdownHeight - 4 : rect.bottom + 4, left: Math.max(8, Math.min(rect.right - 170, window.innerWidth - 180)) })
52
+ }
53
+ setOpen((current) => !current)
54
+ }
55
+
56
+ return <div className="cms-row-menu">
57
+ <button ref={triggerRef} type="button" className="cms-row-menu-trigger" onClick={toggle} aria-label="Actions"><MoreIcon /></button>
58
+ {open && position && <Portal><div ref={menuRef} className="cms-row-menu-dropdown" style={{ position: 'fixed', top: position.top, left: position.left }}>
59
+ {items.map((item, index) => item.href
60
+ ? <a key={index} href={item.href} target={item.newTab ? '_blank' : undefined} rel={item.newTab ? 'noreferrer' : undefined} className={item.danger ? 'danger' : ''} onClick={() => setOpen(false)}>{item.icon && <ActionIcon name={item.icon} />}{item.label}</a>
61
+ : <Button key={index} variant={item.danger ? 'negative' : 'ghost'} compact type="button" className={item.danger ? 'danger' : ''} onClick={() => { item.onClick?.(); setOpen(false) }} icon={item.icon ? <ActionIcon name={item.icon} /> : undefined} iconPosition="before">{item.label}</Button>)}
62
+ </div></Portal>}
63
+ </div>
64
+ }