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,420 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { CmsSidebar } from './CmsSidebar'
6
+ import { EditorHeader } from './EditorHeader'
7
+ import { EditorSkeleton } from './EditorSkeleton'
8
+ import { ContentEditorForm } from './ContentEditorForm'
9
+ import { ContentViewSettings as ContentViewSettingsModal } from './ContentViewSettings'
10
+ import { DynamicField } from './DynamicField'
11
+ import { Button } from './Button'
12
+ import { PublishingPanel, type ReviewStatus } from './PublishingPanel'
13
+ import { RecordMeta } from './RecordMeta'
14
+ import { useToast } from './Toast'
15
+ import { useCmsSiteSettings } from './CmsSiteSettingsProvider'
16
+ import { useCmsLocales } from './CmsLocalesProvider'
17
+ import { formatDateTime } from '../format'
18
+ import { contentViewField, defaultContentViewFor, type ContentViewSettings as ContentViewConfig } from '../collections/content-view'
19
+ import { relationFields } from '../collections/registry'
20
+ import { emptyRecord, isFieldVisible } from '../collections/values'
21
+ import { combineScheduledAt } from '../collections/schedule'
22
+ import { workflowColumnKeys, type CollectionConfig, type SelectOption } from '../collections/types'
23
+
24
+ type Row = Record<string, unknown>
25
+ type UserOption = { id: string; name: string }
26
+ type SavingState = 'draft' | 'publish' | 'unpublish' | 'schedule' | 'unschedule' | 'workflow' | null
27
+ const recordSignature = (row: Row) => JSON.stringify(Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'id' && key !== 'createdAt' && key !== 'updatedAt' && !(workflowColumnKeys as readonly string[]).includes(key))))
28
+
29
+ export function CollectionRecordEditor({ config, id, userName, currentUserId, canCreate, canUpdate, canPublish, canDelete, allowedFields, canConfigureViews, canSubmitReview, canReview, canApprove, isAdmin }: {
30
+ config: CollectionConfig
31
+ id: string
32
+ userName: string
33
+ currentUserId: string
34
+ canCreate: boolean
35
+ canUpdate: boolean
36
+ canPublish: boolean
37
+ canDelete: boolean
38
+ allowedFields: string[]
39
+ canConfigureViews: boolean
40
+ canSubmitReview: boolean
41
+ canReview: boolean
42
+ canApprove: boolean
43
+ isAdmin: boolean
44
+ }) {
45
+ // The server page already resolved `config` (registry lookups only work server-side —
46
+ // `defineKiloConfig()` never runs on the client) and passes it down as a prop; `slug` is
47
+ // just a local alias so every existing `slug`-referencing line below keeps working unchanged.
48
+ const slug = config.slug
49
+ const router = useRouter()
50
+ const toast = useToast()
51
+ const { settings } = useCmsSiteSettings()
52
+ const localeConfig = useCmsLocales()
53
+ const isNew = id === 'new'
54
+
55
+ const [record, setRecord] = useState<Row>(() => emptyRecord(config, localeConfig))
56
+ const [view, setView] = useState<ContentViewConfig>(() => defaultContentViewFor(config))
57
+ const [relationOptions, setRelationOptions] = useState<Record<string, SelectOption[]>>({})
58
+ const [errors, setErrors] = useState<Record<string, string>>({})
59
+ const [saving, setSaving] = useState<SavingState>(null)
60
+ const [loading, setLoading] = useState(!isNew)
61
+ const [status, setStatus] = useState(isNew ? `New ${config.labelSingular.toLowerCase()}` : 'Loading…')
62
+ const [savedSignature, setSavedSignature] = useState<string | null>(null)
63
+ const [savedPublished, setSavedPublished] = useState(false)
64
+ const [savedScheduledAt, setSavedScheduledAt] = useState<string | null>(null)
65
+ const [viewSettingsOpen, setViewSettingsOpen] = useState(false)
66
+ const [users, setUsers] = useState<UserOption[]>([])
67
+
68
+ useEffect(() => {
69
+ fetch(`/api/admin/content-views/${slug}`).then((response) => response.ok ? response.json() : null).then((data) => { if (data) setView(data) }).catch(() => {})
70
+ if (config.workflow?.audit || config.workflow?.review) fetch('/api/admin/users').then((response) => response.ok ? response.json() : []).then(setUsers).catch(() => {})
71
+
72
+ async function loadRelationOptions(current: Row) {
73
+ const entries = await Promise.all(relationFields(config).map(async (field) => {
74
+ const currentValue = current[field.key]
75
+ const ids = field.hasMany
76
+ ? (Array.isArray(currentValue) ? currentValue.filter((item): item is string => typeof item === 'string' && item !== '') : [])
77
+ : (typeof currentValue === 'string' && currentValue ? [currentValue] : [])
78
+ const query = ids.length ? `?include=${encodeURIComponent(ids.join(','))}` : ''
79
+ const options = await fetch(`/api/admin/collections/${field.relationTo}/options${query}`).then((response) => response.ok ? response.json() : []).catch(() => [])
80
+ return [field.key, options] as const
81
+ }))
82
+ setRelationOptions(Object.fromEntries(entries))
83
+ }
84
+
85
+ if (isNew) {
86
+ const fresh = { ...emptyRecord(config, localeConfig), ...(config.workflow?.audit ? { ownerId: currentUserId } : {}) }
87
+ setRecord(fresh)
88
+ setSavedSignature(recordSignature(fresh))
89
+ setSavedPublished(false)
90
+ setStatus(`New ${config.labelSingular.toLowerCase()}`)
91
+ setLoading(false)
92
+ void loadRelationOptions(fresh)
93
+ return
94
+ }
95
+
96
+ setLoading(true)
97
+ fetch(`/api/admin/collections/${slug}/${id}`)
98
+ .then(async (response) => { if (!response.ok) throw new Error(); return response.json() })
99
+ .then((data: Row) => {
100
+ applyServer(data)
101
+ setStatus(Boolean(data.published) ? 'Published' : 'Draft')
102
+ void loadRelationOptions(data)
103
+ })
104
+ .catch(() => setStatus(`Could not load this ${config.labelSingular.toLowerCase()}`))
105
+ .finally(() => setLoading(false))
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [slug, id, isNew])
108
+
109
+ function update(key: string, value: unknown) {
110
+ setRecord((current) => ({ ...current, [key]: value }))
111
+ setErrors((current) => {
112
+ if (!(key in current)) return current
113
+ const next = { ...current }
114
+ delete next[key]
115
+ return next
116
+ })
117
+ }
118
+
119
+ function validate(): Record<string, string> {
120
+ const next: Record<string, string> = {}
121
+ for (const field of config.fields) {
122
+ if (field.readOnly || !field.required) continue
123
+ const value = record[field.key]
124
+ if (value === null || value === undefined || value === '') next[field.key] = 'This field is required.'
125
+ }
126
+ setErrors(next)
127
+ return next
128
+ }
129
+
130
+ function applyServer(data: Row) {
131
+ setRecord(data)
132
+ setSavedSignature(recordSignature(data))
133
+ setSavedPublished(Boolean(data.published))
134
+ setSavedScheduledAt((data.scheduledAt as string | null) ?? null)
135
+ return data
136
+ }
137
+
138
+ async function save(publish = false) {
139
+ const validationErrors = !config.workflow?.publish || publish ? validate() : {}
140
+ if (Object.keys(validationErrors).length) {
141
+ const names = Object.keys(validationErrors).map((key) => contentViewField(view, key)?.label || config.fields.find((field) => field.key === key)?.label || key)
142
+ toast.error(`Cannot ${publish ? 'publish' : 'save'}. Check: ${names.join(', ')}.`)
143
+ return
144
+ }
145
+ setSaving(config.workflow?.publish ? (publish ? 'publish' : 'draft') : 'draft')
146
+ setStatus(config.workflow?.publish ? (publish ? 'Publishing…' : 'Saving draft…') : 'Saving…')
147
+ try {
148
+ const endpoint = isNew ? `/api/admin/collections/${slug}` : `/api/admin/collections/${slug}/${id}`
149
+ const response = await fetch(endpoint, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config.workflow?.publish ? { ...record, published: publish } : record) })
150
+ const body = await response.text()
151
+ const data = body ? JSON.parse(body) : null
152
+ if (!response.ok) throw new Error(data?.error || `Save failed (status ${response.status})`)
153
+ applyServer(data)
154
+ setStatus(config.workflow?.publish ? (publish ? 'Published live' : 'Saved as draft') : 'Saved')
155
+ toast.success(config.workflow?.publish ? (publish ? `${config.labelSingular} published live.` : `${config.labelSingular} saved as draft.`) : `${config.labelSingular} saved.`)
156
+ if (isNew) router.replace(`/admin/collections/${slug}/${data.id}`)
157
+ } catch (error) {
158
+ const message = error instanceof Error ? error.message : 'Could not save'
159
+ setStatus(message)
160
+ toast.error(message)
161
+ } finally {
162
+ setSaving(null)
163
+ }
164
+ }
165
+
166
+ async function unpublish() {
167
+ setSaving('unpublish')
168
+ setStatus('Unpublishing…')
169
+ try {
170
+ const response = await fetch(`/api/admin/collections/${slug}/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ published: false }) })
171
+ const body = await response.text()
172
+ const data = body ? JSON.parse(body) : null
173
+ if (!response.ok) throw new Error(data?.error || `Unpublish failed (status ${response.status})`)
174
+ applyServer(data)
175
+ setStatus('Unpublished — saved as draft')
176
+ toast.success(`${config.labelSingular} unpublished and moved to draft.`)
177
+ } catch (error) {
178
+ const message = error instanceof Error ? error.message : `Could not unpublish this ${config.labelSingular.toLowerCase()}`
179
+ setStatus(message)
180
+ toast.error(message)
181
+ } finally {
182
+ setSaving(null)
183
+ }
184
+ }
185
+
186
+ async function submitReview(reviewerId: string) {
187
+ setSaving('workflow')
188
+ setStatus('Submitting for review…')
189
+ try {
190
+ const response = await fetch(`/api/admin/collections/${slug}/${id}/submit-review`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewerId }) })
191
+ const data = await response.json()
192
+ if (!response.ok) throw new Error(data?.error || 'Could not submit for review')
193
+ applyServer(data)
194
+ setStatus('In review')
195
+ toast.success('Submitted for review.')
196
+ } catch (error) {
197
+ toast.error(error instanceof Error ? error.message : 'Could not submit for review')
198
+ } finally {
199
+ setSaving(null)
200
+ }
201
+ }
202
+
203
+ async function reviewDecision(decision: 'approve' | 'request_changes') {
204
+ setSaving('workflow')
205
+ setStatus(decision === 'approve' ? 'Passing review…' : 'Requesting changes…')
206
+ try {
207
+ const response = await fetch(`/api/admin/collections/${slug}/${id}/review`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision }) })
208
+ const data = await response.json()
209
+ if (!response.ok) throw new Error(data?.error || 'Could not update review')
210
+ applyServer(data)
211
+ setStatus(decision === 'approve' ? 'Passed review' : 'Sent back to draft')
212
+ toast.success(decision === 'approve' ? 'Marked as reviewed.' : 'Sent back for changes.')
213
+ } catch (error) {
214
+ toast.error(error instanceof Error ? error.message : 'Could not update review')
215
+ } finally {
216
+ setSaving(null)
217
+ }
218
+ }
219
+
220
+ async function approve() {
221
+ setSaving('workflow')
222
+ setStatus('Approving…')
223
+ try {
224
+ const response = await fetch(`/api/admin/collections/${slug}/${id}/approve`, { method: 'PATCH' })
225
+ const data = await response.json()
226
+ if (!response.ok) throw new Error(data?.error || 'Could not approve')
227
+ applyServer(data)
228
+ setStatus('Approved')
229
+ toast.success(`${config.labelSingular} approved — ready to publish.`)
230
+ } catch (error) {
231
+ toast.error(error instanceof Error ? error.message : 'Could not approve')
232
+ } finally {
233
+ setSaving(null)
234
+ }
235
+ }
236
+
237
+ const scheduling = config.workflow?.scheduling
238
+ const scheduleIso = scheduling
239
+ ? combineScheduledAt(
240
+ typeof record[scheduling.dateField] === 'string' ? (record[scheduling.dateField] as string) : null,
241
+ scheduling.timeField && typeof record[scheduling.timeField] === 'string' ? (record[scheduling.timeField] as string) : null,
242
+ )
243
+ : null
244
+
245
+ /** Saves the current edits first (so scheduling can't strand unsaved content), then transitions.
246
+ * Mirrors the old BlogEditor.schedule(), which did both in one PUT. */
247
+ async function schedule() {
248
+ if (!scheduleIso) { toast.error('Pick a scheduled date first.'); return }
249
+ const validationErrors = validate()
250
+ if (Object.keys(validationErrors).length) {
251
+ const names = Object.keys(validationErrors).map((key) => contentViewField(view, key)?.label || config.fields.find((field) => field.key === key)?.label || key)
252
+ setStatus('Fix the highlighted fields')
253
+ toast.error(`Cannot schedule. Check: ${names.join(', ')}.`)
254
+ return
255
+ }
256
+ setSaving('schedule')
257
+ setStatus('Scheduling…')
258
+ try {
259
+ const saveEndpoint = isNew ? `/api/admin/collections/${slug}` : `/api/admin/collections/${slug}/${id}`
260
+ const saved = await fetch(saveEndpoint, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...record, published: false }) })
261
+ const savedBody = await saved.text()
262
+ const savedData = savedBody ? JSON.parse(savedBody) : null
263
+ if (!saved.ok) throw new Error(savedData?.error || `Save failed (status ${saved.status})`)
264
+ const targetId = String(savedData.id)
265
+ const response = await fetch(`/api/admin/collections/${slug}/${targetId}/schedule`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scheduledAt: scheduleIso }) })
266
+ const body = await response.text()
267
+ const data = body ? JSON.parse(body) : null
268
+ if (!response.ok) throw new Error(data?.error || `Schedule failed (status ${response.status})`)
269
+ applyServer(data)
270
+ setStatus('Scheduled')
271
+ toast.success(`${config.labelSingular} scheduled.`)
272
+ if (isNew) router.replace(`/admin/collections/${slug}/${targetId}`)
273
+ } catch (error) {
274
+ const message = error instanceof Error ? error.message : 'Could not schedule'
275
+ setStatus(message)
276
+ toast.error(message)
277
+ } finally {
278
+ setSaving(null)
279
+ }
280
+ }
281
+
282
+ async function unschedule() {
283
+ setSaving('unschedule')
284
+ setStatus('Removing schedule…')
285
+ try {
286
+ const response = await fetch(`/api/admin/collections/${slug}/${id}/unschedule`, { method: 'PATCH' })
287
+ const body = await response.text()
288
+ const data = body ? JSON.parse(body) : null
289
+ if (!response.ok) throw new Error(data?.error || `Could not unschedule (status ${response.status})`)
290
+ applyServer(data)
291
+ setStatus('Draft')
292
+ toast.success('Schedule removed.')
293
+ } catch (error) {
294
+ const message = error instanceof Error ? error.message : 'Could not unschedule'
295
+ setStatus(message)
296
+ toast.error(message)
297
+ } finally {
298
+ setSaving(null)
299
+ }
300
+ }
301
+
302
+ async function remove() {
303
+ if (!confirm(`Delete this ${config.labelSingular.toLowerCase()}? This cannot be undone.`)) return
304
+ const response = await fetch(`/api/admin/collections/${slug}/${id}`, { method: 'DELETE' })
305
+ if (response.ok) { toast.success(`${config.labelSingular} deleted.`); router.push(`/admin/collections/${slug}`) }
306
+ else { const body = await response.json().catch(() => null); toast.error(body?.error || `Could not delete this ${config.labelSingular.toLowerCase()}.`) }
307
+ }
308
+
309
+ async function saveView(next: ContentViewConfig) {
310
+ const response = await fetch(`/api/admin/content-views/${slug}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(next) })
311
+ const data = await response.json()
312
+ if (!response.ok) throw new Error(data.error || 'Could not save view settings.')
313
+ setView(data)
314
+ toast.success(`${config.label} view settings saved.`)
315
+ }
316
+
317
+ const dirty = savedSignature !== null && savedSignature !== recordSignature(record)
318
+ const reviewStatus = isReviewStatus(record.reviewStatus) ? record.reviewStatus : 'draft'
319
+ const reviewerName = users.find((item) => item.id === record.reviewerId)?.name ?? null
320
+ const createdByName = users.find((item) => item.id === record.createdBy)?.name ?? null
321
+ const updatedByName = users.find((item) => item.id === record.updatedBy)?.name ?? null
322
+ const approvedByName = users.find((item) => item.id === record.approverId)?.name ?? null
323
+ const canActuallyReview = canReview && (isAdmin || record.reviewerId === currentUserId)
324
+ const reviewerOptions = users.filter((item) => item.id !== record.createdBy)
325
+ const requiresApproval = config.workflow?.approval === 'always'
326
+ ? true
327
+ : config.workflow?.approval === 'never'
328
+ ? false
329
+ : settings.publishingWorkflow.approvalRequired.includes(config.slug)
330
+
331
+ const fields: Record<string, ReactNode> = Object.fromEntries(
332
+ config.fields
333
+ .filter((field) => !field.hidden && isFieldVisible(field, record) && (field.readOnly || allowedFields.includes(field.key)))
334
+ .map((field) => [field.key, (
335
+ <DynamicField
336
+ key={field.key}
337
+ field={field}
338
+ value={record[field.key]}
339
+ onChange={(value) => update(field.key, value)}
340
+ label={contentViewField(view, field.key)?.label || field.label}
341
+ hint={contentViewField(view, field.key)?.description || field.hint}
342
+ error={errors[field.key]}
343
+ disabled={!canUpdate && !isNew}
344
+ relationOptions={field.type === 'relation' ? relationOptions[field.key] : undefined}
345
+ />
346
+ )]),
347
+ )
348
+
349
+ const sidePanel = config.workflow?.publish ? (
350
+ <PublishingPanel
351
+ published={savedPublished}
352
+ dirty={dirty}
353
+ saving={saving}
354
+ message={status}
355
+ publishedAt={record.publishedAt as string | null | undefined}
356
+ scheduledAt={savedScheduledAt}
357
+ canSchedule={Boolean(scheduleIso)}
358
+ onSchedule={scheduling && canPublish ? () => void schedule() : undefined}
359
+ onUnschedule={scheduling && canPublish && !isNew ? () => void unschedule() : undefined}
360
+ onSaveDraft={() => void save(false)}
361
+ onPublish={() => void save(true)}
362
+ onUnpublish={!isNew && canPublish ? () => void unpublish() : undefined}
363
+ onTrash={isNew || !canDelete ? undefined : () => void remove()}
364
+ reviewStatus={reviewStatus}
365
+ requiresApproval={requiresApproval}
366
+ reviewerName={reviewerName}
367
+ canSubmitReview={canSubmitReview && !isNew}
368
+ canReview={canActuallyReview}
369
+ canApprove={canApprove}
370
+ reviewers={reviewerOptions}
371
+ onSubmitReview={(reviewerId) => void submitReview(reviewerId)}
372
+ onReviewDecision={(decision) => void reviewDecision(decision)}
373
+ onApprove={() => void approve()}
374
+ />
375
+ ) : (
376
+ <div className="cms-editor-sidebar-stack">
377
+ <aside className="cms-editor-sidebar">
378
+ <div className="cms-publishing-heading"><span>{config.labelSingular}</span></div>
379
+ <div className="cms-publishing-summary">{dirty && <span className="cms-unsaved-status">Unsaved changes</span>}</div>
380
+ <div className="cms-publishing-actions">
381
+ <Button variant="positive" disabled={saving !== null || (!isNew && !canUpdate) || (isNew && !canCreate)} onClick={() => void save(false)}>{saving ? 'Saving…' : 'Save'}</Button>
382
+ </div>
383
+ {!isNew && canDelete && <Button variant="negative" onClick={() => void remove()}>Delete</Button>}
384
+ <p className="cms-ai-status">{status}</p>
385
+ </aside>
386
+ {!isNew && <aside className="cms-content-records">
387
+ <div className="cms-records-heading"><strong>Record</strong></div>
388
+ <section className="cms-record-section">
389
+ <div className="cms-meta-list">
390
+ <div><span>ID</span><strong>{String(record.id ?? '—')}</strong></div>
391
+ {config.fields.some((field) => field.key === 'createdAt') && <div><span>Created</span><strong>{formatDateTime(record.createdAt as string)}</strong></div>}
392
+ {config.touchUpdatedAt && <div><span>Last updated</span><strong>{formatDateTime(record.updatedAt as string)}</strong></div>}
393
+ </div>
394
+ </section>
395
+ </aside>}
396
+ </div>
397
+ )
398
+
399
+ return <main className="cms-dashboard">
400
+ <CmsSidebar active="collections" userName={userName} contentContext={{ title: config.label, backHref: `/admin/collections/${slug}`, backLabel: `All ${config.label.toLowerCase()}`, settings: view }} />
401
+ <section className="cms-content">
402
+ <EditorHeader
403
+ compact
404
+ title={isNew ? `New ${config.labelSingular.toLowerCase()}` : String(record[config.titleField] ?? config.labelSingular)}
405
+ backHref={`/admin/collections/${slug}`}
406
+ backLabel={`All ${config.label.toLowerCase()}`}
407
+ actions={canConfigureViews ? <Button type="button" variant="secondary" className="cms-view-settings-button" icon="⚙" iconPosition="before" onClick={() => setViewSettingsOpen(true)}>View settings</Button> : undefined}
408
+ />
409
+ {loading ? <EditorSkeleton /> : <div className="cms-editor-layout">
410
+ <ContentEditorForm settings={view} fields={fields} groupExtras={{ record: <RecordMeta createdByName={createdByName} updatedByName={updatedByName} approvedByName={approvedByName} ownerId={typeof record.ownerId === 'string' ? record.ownerId : null} owners={users} onOwnerChange={(ownerId) => update('ownerId', ownerId || null)} /> }} />
411
+ {sidePanel}
412
+ </div>}
413
+ </section>
414
+ {viewSettingsOpen && <ContentViewSettingsModal title={config.label} value={view} onClose={() => setViewSettingsOpen(false)} onSave={saveView} />}
415
+ </main>
416
+ }
417
+
418
+ function isReviewStatus(value: unknown): value is ReviewStatus {
419
+ return value === 'draft' || value === 'in_review' || value === 'reviewed' || value === 'approved'
420
+ }
@@ -0,0 +1,21 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import type { ContentViewSettings } from '../collections/content-view'
5
+
6
+ type Props = { settings: ContentViewSettings; fields: Record<string, ReactNode>; groupExtras?: Record<string, ReactNode> }
7
+
8
+ export function ContentEditorForm({ settings, fields, groupExtras }: Props) {
9
+ return <div className="cms-editor-form cms-content-editor-form">
10
+ {settings.groups.map((group) => {
11
+ const groupFields = settings.fields.filter((field) => field.groupId === group.id && fields[field.key])
12
+ const extra = groupExtras?.[group.id]
13
+ if (!groupFields.length && !extra) return null
14
+ return <section className="cms-section cms-content-field-group" id={`cms-group-${group.id}`} key={group.id}>
15
+ <div className="cms-section-heading"><p className="section-label">{group.label}</p>{group.description && <p>{group.description}</p>}</div>
16
+ {groupFields.length > 0 && <div className="cms-content-field-grid">{groupFields.map((field) => <div id={`cms-field-${field.key}`} key={field.key} className={`cms-content-field cms-content-field-${field.width}`}>{fields[field.key]}</div>)}</div>}
17
+ {extra}
18
+ </section>
19
+ })}
20
+ </div>
21
+ }
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { useState, type DragEvent } from 'react'
4
+ import { Reorder, useDragControls } from 'framer-motion'
5
+ import { CloseButton } from './CloseButton'
6
+ import { Button } from './Button'
7
+ import { Portal } from './Portal'
8
+ import { useBodyScrollLock } from '../useBodyScrollLock'
9
+ import type { ContentViewField, ContentViewGroup, ContentViewSettings, ContentViewFieldWidth } from '../collections/content-view'
10
+
11
+ type Props = { title: string; value: ContentViewSettings; onClose: () => void; onSave: (value: ContentViewSettings) => Promise<void> }
12
+ const widths: { value: ContentViewFieldWidth; label: string }[] = [{ value: 'full', label: 'Full' }, { value: 'half', label: '½' }, { value: 'third', label: '⅓' }]
13
+
14
+ function DragHandle() { return <svg viewBox="0 0 12 16" aria-hidden="true"><circle cx="3" cy="2" r="1.4" /><circle cx="9" cy="2" r="1.4" /><circle cx="3" cy="8" r="1.4" /><circle cx="9" cy="8" r="1.4" /><circle cx="3" cy="14" r="1.4" /><circle cx="9" cy="14" r="1.4" /></svg> }
15
+
16
+ function FieldCard({ field, assigned, onUpdate, onRemove, onDropBefore, onDragStart }: { field: ContentViewField; assigned: boolean; onUpdate: (patch: Partial<ContentViewField>) => void; onRemove: () => void; onDropBefore: (event: DragEvent<HTMLElement>) => void; onDragStart: (event: DragEvent<HTMLElement>) => void }) {
17
+ return <article className={`content-view-field content-view-field-${field.width}`} draggable onDragStart={onDragStart} onDragOver={(event) => event.preventDefault()} onDrop={onDropBefore}>
18
+ <span className="content-view-field-grip" title="Drag to move"><DragHandle /></span>
19
+ <div className="content-view-field-name"><input value={field.label} aria-label={`${field.key} label`} onChange={(event) => onUpdate({ label: event.target.value })} /><input className="content-view-description-input" value={field.description || ''} aria-label={`${field.key} description`} onChange={(event) => onUpdate({ description: event.target.value })} placeholder="Short helper description (optional)" /><small>{field.key}</small></div>
20
+ <div className="content-view-width" aria-label={`${field.label} width`}>{widths.map((width) => <button type="button" key={width.value} className={field.width === width.value ? 'active' : ''} onClick={() => onUpdate({ width: width.value })}>{width.label}</button>)}</div>
21
+ {assigned && <button type="button" className="content-view-field-remove" onClick={onRemove} aria-label={`Move ${field.label} to available fields`} title="Move to available fields">×</button>}
22
+ </article>
23
+ }
24
+
25
+ function GroupCard({ group, fields, canRemove, onRename, onDescribe, onRemove, onFieldDrop, onFieldUpdate, onFieldRemove, onCardDrop, onDragStart }: { group: ContentViewGroup; fields: ContentViewField[]; canRemove: boolean; onRename: (label: string) => void; onDescribe: (description: string) => void; onRemove: () => void; onFieldDrop: (event: DragEvent<HTMLElement>) => void; onFieldUpdate: (key: string, patch: Partial<ContentViewField>) => void; onFieldRemove: (key: string) => void; onCardDrop: (event: DragEvent<HTMLElement>, field: ContentViewField) => void; onDragStart: (event: DragEvent<HTMLElement>, key: string) => void }) {
26
+ const controls = useDragControls()
27
+ return <Reorder.Item value={group} dragListener={false} dragControls={controls} className="content-view-group">
28
+ <div className="content-view-group-head"><button type="button" className="content-view-group-grip" onPointerDown={(event) => controls.start(event)} aria-label={`Drag to reorder ${group.label}`}><DragHandle /></button><div><input value={group.label} aria-label="Group name" onChange={(event) => onRename(event.target.value)} /><input className="content-view-description-input" value={group.description || ''} aria-label="Group description" onChange={(event) => onDescribe(event.target.value)} placeholder="Short group description (optional)" /></div><button type="button" className="content-view-group-remove" disabled={!canRemove} onClick={onRemove} title="Remove group">×</button></div>
29
+ <div className="content-view-group-drop" onDragOver={(event) => event.preventDefault()} onDrop={onFieldDrop}>{fields.length ? fields.map((field) => <FieldCard key={field.key} field={field} assigned onUpdate={(patch) => onFieldUpdate(field.key, patch)} onRemove={() => onFieldRemove(field.key)} onDropBefore={(event) => onCardDrop(event, field)} onDragStart={(event) => onDragStart(event, field.key)} />) : <p>Drop fields here</p>}</div>
30
+ </Reorder.Item>
31
+ }
32
+
33
+ export function ContentViewSettings({ title, value, onClose, onSave }: Props) {
34
+ const [settings, setSettings] = useState<ContentViewSettings>(value)
35
+ const [saving, setSaving] = useState(false)
36
+ const [error, setError] = useState('')
37
+ useBodyScrollLock(true)
38
+
39
+ function updateField(key: string, patch: Partial<ContentViewField>) { setSettings((current) => ({ ...current, fields: current.fields.map((field) => field.key === key ? { ...field, ...patch } : field) })) }
40
+ function placeField(key: string, groupId: string | null, beforeKey?: string) { setSettings((current) => { const moving = current.fields.find((field) => field.key === key); if (!moving) return current; const rest = current.fields.filter((field) => field.key !== key); const field = { ...moving, groupId }; const beforeIndex = beforeKey ? rest.findIndex((item) => item.key === beforeKey) : -1; const lastInGroup = groupId ? rest.reduce((last, item, index) => item.groupId === groupId ? index : last, -1) : rest.length - 1; const index = beforeIndex >= 0 ? beforeIndex : lastInGroup + 1; rest.splice(index, 0, field); return { ...current, fields: rest } }) }
41
+ function fieldKey(event: DragEvent<HTMLElement>) { return event.dataTransfer.getData('application/x-cms-field') || event.dataTransfer.getData('text/plain') }
42
+ function startFieldDrag(event: DragEvent<HTMLElement>, key: string) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-cms-field', key); event.dataTransfer.setData('text/plain', key) }
43
+ function addGroup() { setSettings((current) => ({ ...current, groups: [...current.groups, { id: `group-${crypto.randomUUID().slice(0, 8)}`, label: 'New group' }] })) }
44
+ function removeGroup(id: string) { setSettings((current) => ({ groups: current.groups.filter((group) => group.id !== id), fields: current.fields.map((field) => field.groupId === id ? { ...field, groupId: null } : field) })) }
45
+ async function save() { setSaving(true); setError(''); try { await onSave(settings); onClose() } catch (caught) { setError(caught instanceof Error ? caught.message : 'Could not save view settings.') } finally { setSaving(false) } }
46
+ const available = settings.fields.filter((field) => !field.groupId)
47
+
48
+ return <Portal><div className="content-view-settings-modal" role="dialog" aria-modal="true" aria-label={`${title} view settings`} onClick={onClose}><section className="content-view-settings-panel" onClick={(event) => event.stopPropagation()}>
49
+ <header><div><p className="section-label">Content model</p><strong>{title} view settings</strong></div><div className="content-view-header-actions"><Button variant="primary" type="button" disabled={saving} onClick={() => void save()} icon="↗">{saving ? 'Saving…' : 'Save settings'}</Button><CloseButton onClick={onClose} /></div></header>
50
+ <p className="content-view-settings-intro">Drag fields from Available fields into a group. Field labels update the matching listing-table labels for every CMS user.</p>
51
+ <div className="content-view-workspace"><section className="content-view-group-column"><div className="content-view-settings-title"><p className="listing-settings-label">Editor groups</p><Button variant="secondary" compact type="button" onClick={addGroup}>+ Add group</Button></div><Reorder.Group axis="y" values={settings.groups} onReorder={(groups) => setSettings((current) => ({ ...current, groups }))} className="content-view-groups">{settings.groups.map((group) => <GroupCard key={group.id} group={group} fields={settings.fields.filter((field) => field.groupId === group.id)} canRemove={settings.groups.length > 1} onRename={(label) => setSettings((current) => ({ ...current, groups: current.groups.map((item) => item.id === group.id ? { ...item, label } : item) }))} onDescribe={(description) => setSettings((current) => ({ ...current, groups: current.groups.map((item) => item.id === group.id ? { ...item, description } : item) }))} onRemove={() => removeGroup(group.id)} onFieldDrop={(event) => { event.preventDefault(); const key = fieldKey(event); if (key) placeField(key, group.id) }} onFieldUpdate={updateField} onFieldRemove={(key) => placeField(key, null)} onCardDrop={(event, field) => { event.preventDefault(); const key = fieldKey(event); if (key && key !== field.key) placeField(key, group.id, field.key) }} onDragStart={startFieldDrag} />)}</Reorder.Group></section>
52
+ <section className="content-view-available-column"><p className="listing-settings-label">Available fields</p><p className="content-view-available-hint">These fields are hidden from the editor until dropped into a group.</p><div className="content-view-available-drop" onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); const key = fieldKey(event); if (key) placeField(key, null) }}>{available.length ? available.map((field) => <FieldCard key={field.key} field={field} assigned={false} onUpdate={(patch) => updateField(field.key, patch)} onRemove={() => undefined} onDropBefore={(event) => { event.preventDefault(); const key = fieldKey(event); if (key && key !== field.key) placeField(key, null, field.key) }} onDragStart={(event) => startFieldDrag(event, field.key)} />) : <p>All fields are in groups</p>}</div></section></div>
53
+ {error && <p className="content-view-settings-error">{error}</p>}
54
+ </section></div></Portal>
55
+ }