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.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- 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
|
+
}
|