@nuasite/collections-admin 0.43.0-beta.1 → 0.43.0-beta.3

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.
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Entry editor — editable form built from a collection's `FieldDefinition[]`,
3
+ * with debounced optimistic save and `409` conflict resolution (cms-headless F3.2).
4
+ *
5
+ * Layout is driven by the field definitions:
6
+ * - `hidden` fields are dropped.
7
+ * - `position: 'header'` fields render in a top strip; `position: 'sidebar'` in a
8
+ * side column; the rest in the main column. `role` (`publish-toggle`/`publish-date`)
9
+ * pins a field to the top of the sidebar and styles it as a publish control.
10
+ * - `group` inserts a section header within a column.
11
+ * - the markdown `body` is edited as a textarea below the main fields.
12
+ *
13
+ * Save flow (see `phase-3-collections-tab.md`): edits update a native draft and
14
+ * schedule a debounced `PATCH { frontmatter, body, baseHash }`. `GET …/entries/:slug`
15
+ * exposes no hash, so the first save sends no `baseHash` (the sidecar skips the
16
+ * check) and adopts `MutationResult.sourceHash` as the new baseHash; later saves
17
+ * carry it. A `409` opens the conflict dialog: "use server" adopts the server copy
18
+ * + `serverHash`; "use ours" re-PATCHes with `baseHash = serverHash` (force-over).
19
+ */
20
+
21
+ import type { CollectionDefinition, FieldDefinition } from '@nuasite/cms-types'
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
23
+ import type { CmsClient, CmsConflict } from './client'
24
+ import { CmsClientError } from './client'
25
+ import { type EditorContext, FieldEditor } from './field-editor'
26
+ import { draftFromEntry, draftFromServerFrontmatter, type EntryDraft, setDraftField } from './form-model'
27
+
28
+ const SAVE_DEBOUNCE_MS = 700
29
+
30
+ type SaveStatus =
31
+ | { kind: 'idle' }
32
+ | { kind: 'saving' }
33
+ | { kind: 'saved' }
34
+ | { kind: 'conflict'; conflict: CmsConflict }
35
+ | { kind: 'error'; message: string }
36
+
37
+ interface PartitionedFields {
38
+ header: FieldDefinition[]
39
+ sidebar: FieldDefinition[]
40
+ main: FieldDefinition[]
41
+ }
42
+
43
+ /**
44
+ * Split visible fields into layout columns. Sidebar leads with `role`-tagged
45
+ * publish fields (toggle then date), then other sidebar-positioned fields.
46
+ */
47
+ function partitionFields(fields: FieldDefinition[]): PartitionedFields {
48
+ const visible = fields.filter(f => !f.hidden)
49
+ const header = visible.filter(f => f.position === 'header')
50
+ // A `role`-tagged field lives in the sidebar even without an explicit position.
51
+ const sidebarPositioned = visible.filter(f => f.position === 'sidebar' || (f.role !== undefined && f.position !== 'header'))
52
+ const main = visible.filter(f => f.position === undefined && f.role === undefined)
53
+
54
+ const roleRank = (f: FieldDefinition): number => (f.role === 'publish-toggle' ? 0 : f.role === 'publish-date' ? 1 : 2)
55
+ const sidebar = sidebarPositioned.slice().sort((a, b) => roleRank(a) - roleRank(b))
56
+ return { header, sidebar, main }
57
+ }
58
+
59
+ /** Render a column of fields, emitting a section header whenever `group` changes. */
60
+ function FieldColumn({ fields, draft, onField, ctx }: {
61
+ fields: FieldDefinition[]
62
+ draft: EntryDraft
63
+ onField: (name: string, value: unknown) => void
64
+ ctx: EditorContext
65
+ }) {
66
+ let lastGroup: string | undefined
67
+ const rows: React.ReactNode[] = []
68
+ for (const field of fields) {
69
+ if (field.group && field.group !== lastGroup) {
70
+ rows.push(<h4 key={`group-${field.group}`} className="nua-cadmin-group-title">{field.group}</h4>)
71
+ lastGroup = field.group
72
+ }
73
+ rows.push(
74
+ <div key={field.name} className={`nua-cadmin-field${field.role ? ` nua-cadmin-field-${field.role}` : ''}`}>
75
+ <div className="nua-cadmin-field-label">
76
+ <span>{field.name}</span>
77
+ <span className="nua-cadmin-field-type">{field.type}{field.required ? ' · required' : ''}</span>
78
+ </div>
79
+ <FieldEditor field={field} value={draft.frontmatter[field.name]} onChange={value => onField(field.name, value)} ctx={ctx} />
80
+ </div>,
81
+ )
82
+ }
83
+ return <>{rows}</>
84
+ }
85
+
86
+ // ============================================================================
87
+ // Conflict dialog
88
+ // ============================================================================
89
+
90
+ function ConflictDialog({ onUseServer, onUseOurs, onDismiss }: {
91
+ onUseServer: () => void
92
+ onUseOurs: () => void
93
+ onDismiss: () => void
94
+ }) {
95
+ return (
96
+ <div className="nua-cadmin-dialog-backdrop" role="dialog" aria-modal="true" aria-label="Edit conflict">
97
+ <div className="nua-cadmin-dialog">
98
+ <div className="nua-cadmin-dialog-title">This entry changed elsewhere</div>
99
+ <div className="nua-cadmin-dialog-body">
100
+ Someone (or the agent) edited this entry after you opened it. Choose which version to keep.
101
+ </div>
102
+ <div className="nua-cadmin-dialog-actions">
103
+ <button type="button" className="nua-cadmin-btn" onClick={onUseServer}>Use server version</button>
104
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-primary" onClick={onUseOurs}>Keep my changes</button>
105
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-ghost" onClick={onDismiss}>Keep editing</button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ // ============================================================================
113
+ // Status badge
114
+ // ============================================================================
115
+
116
+ function StatusBadge({ status }: { status: SaveStatus }) {
117
+ switch (status.kind) {
118
+ case 'saving':
119
+ return <span className="nua-cadmin-status nua-cadmin-status-saving">Saving…</span>
120
+ case 'saved':
121
+ return <span className="nua-cadmin-status nua-cadmin-status-saved">Saved</span>
122
+ case 'conflict':
123
+ return <span className="nua-cadmin-status nua-cadmin-status-conflict">Conflict</span>
124
+ case 'error':
125
+ return <span className="nua-cadmin-status nua-cadmin-status-error">{status.message}</span>
126
+ default:
127
+ return null
128
+ }
129
+ }
130
+
131
+ // ============================================================================
132
+ // Editor
133
+ // ============================================================================
134
+
135
+ export function EntryEditor({ client, definition, collection, slug, onDeleted, onRenamed }: {
136
+ client: CmsClient
137
+ definition: CollectionDefinition | undefined
138
+ collection: string
139
+ slug: string
140
+ onDeleted: () => void
141
+ onRenamed: (newSlug: string) => void
142
+ }) {
143
+ const fields = useMemo(() => definition?.fields ?? [], [definition])
144
+ const [draft, setDraft] = useState<EntryDraft | null>(null)
145
+ const [loadError, setLoadError] = useState<Error | null>(null)
146
+ const [status, setStatus] = useState<SaveStatus>({ kind: 'idle' })
147
+
148
+ // `baseHash` is the optimistic-concurrency token. It starts undefined (GET
149
+ // exposes no hash) and is adopted from each successful `MutationResult.sourceHash`.
150
+ const baseHashRef = useRef<string | undefined>(undefined)
151
+ const draftRef = useRef<EntryDraft | null>(null)
152
+ draftRef.current = draft
153
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
154
+
155
+ const ctx: EditorContext = useMemo(() => ({ client, collection, slug }), [client, collection, slug])
156
+
157
+ // Load the entry → build the native draft.
158
+ useEffect(() => {
159
+ let active = true
160
+ baseHashRef.current = undefined
161
+ setDraft(null)
162
+ setLoadError(null)
163
+ setStatus({ kind: 'idle' })
164
+ client.getEntry(collection, slug).then(
165
+ entry => {
166
+ if (active) setDraft(draftFromEntry(entry, fields))
167
+ },
168
+ (err: unknown) => {
169
+ if (active) setLoadError(err instanceof Error ? err : new Error(String(err)))
170
+ },
171
+ )
172
+ return () => {
173
+ active = false
174
+ if (timerRef.current) clearTimeout(timerRef.current)
175
+ }
176
+ }, [client, collection, slug, fields])
177
+
178
+ // Persist the current draft. `force` re-uses the server hash to win a conflict.
179
+ const persist = useCallback(
180
+ async (next: EntryDraft, baseHash: string | undefined) => {
181
+ setStatus({ kind: 'saving' })
182
+ try {
183
+ const outcome = await client.updateEntry(collection, slug, {
184
+ frontmatter: next.frontmatter,
185
+ body: next.body,
186
+ baseHash,
187
+ })
188
+ if (outcome.status === 'conflict') {
189
+ setStatus({ kind: 'conflict', conflict: outcome.conflict })
190
+ return
191
+ }
192
+ if (outcome.result.sourceHash !== undefined) baseHashRef.current = outcome.result.sourceHash
193
+ setStatus({ kind: 'saved' })
194
+ } catch (err: unknown) {
195
+ setStatus({ kind: 'error', message: err instanceof CmsClientError ? err.message : 'Save failed' })
196
+ }
197
+ },
198
+ [client, collection, slug],
199
+ )
200
+
201
+ const scheduleSave = useCallback(
202
+ (next: EntryDraft) => {
203
+ if (timerRef.current) clearTimeout(timerRef.current)
204
+ timerRef.current = setTimeout(() => {
205
+ void persist(next, baseHashRef.current)
206
+ }, SAVE_DEBOUNCE_MS)
207
+ },
208
+ [persist],
209
+ )
210
+
211
+ const onField = useCallback(
212
+ (name: string, value: unknown) => {
213
+ setDraft(prev => {
214
+ if (!prev) return prev
215
+ const next = setDraftField(prev, name, value)
216
+ scheduleSave(next)
217
+ return next
218
+ })
219
+ },
220
+ [scheduleSave],
221
+ )
222
+
223
+ const onBody = useCallback(
224
+ (body: string) => {
225
+ setDraft(prev => {
226
+ if (!prev) return prev
227
+ const next = { ...prev, body }
228
+ scheduleSave(next)
229
+ return next
230
+ })
231
+ },
232
+ [scheduleSave],
233
+ )
234
+
235
+ // --- Conflict resolution ---
236
+ // These act on the current `conflict` status; the dialog only renders while
237
+ // `status.kind === 'conflict'`, so reading `status` directly here is safe.
238
+
239
+ const resolveUseServer = useCallback(() => {
240
+ if (status.kind !== 'conflict') return
241
+ const adopted = draftFromServerFrontmatter(status.conflict.serverFrontmatter, status.conflict.serverBody, fields)
242
+ baseHashRef.current = status.conflict.serverHash
243
+ setDraft(adopted)
244
+ setStatus({ kind: 'saved' })
245
+ }, [status, fields])
246
+
247
+ const resolveUseOurs = useCallback(() => {
248
+ if (status.kind !== 'conflict') return
249
+ const current = draftRef.current
250
+ if (current) void persist(current, status.conflict.serverHash)
251
+ }, [status, persist])
252
+
253
+ const dismissConflict = useCallback(() => {
254
+ if (status.kind === 'conflict') setStatus({ kind: 'idle' })
255
+ }, [status])
256
+
257
+ // --- Delete / rename ---
258
+
259
+ const onDelete = useCallback(async () => {
260
+ if (typeof window !== 'undefined' && !window.confirm(`Delete entry "${slug}"? This cannot be undone.`)) return
261
+ try {
262
+ await client.deleteEntry(collection, slug)
263
+ onDeleted()
264
+ } catch (err: unknown) {
265
+ setStatus({ kind: 'error', message: err instanceof CmsClientError ? err.message : 'Delete failed' })
266
+ }
267
+ }, [client, collection, slug, onDeleted])
268
+
269
+ const onRename = useCallback(async () => {
270
+ if (typeof window === 'undefined') return
271
+ const to = window.prompt('Rename entry to (new slug):', slug)
272
+ if (to === null || to.trim() === '' || to === slug) return
273
+ try {
274
+ await client.renameEntry(collection, slug, to.trim())
275
+ onRenamed(to.trim())
276
+ } catch (err: unknown) {
277
+ setStatus({ kind: 'error', message: err instanceof CmsClientError ? err.message : 'Rename failed' })
278
+ }
279
+ }, [client, collection, slug, onRenamed])
280
+
281
+ if (loadError) {
282
+ return (
283
+ <div className="nua-cadmin-error">
284
+ <div className="nua-cadmin-error-title">Could not load entry</div>
285
+ <div>{loadError.message}</div>
286
+ </div>
287
+ )
288
+ }
289
+ if (!draft) {
290
+ return (
291
+ <div className="nua-cadmin-state">
292
+ <div className="nua-cadmin-spinner" />
293
+ <div>Loading entry…</div>
294
+ </div>
295
+ )
296
+ }
297
+
298
+ const { header, sidebar, main } = partitionFields(fields)
299
+
300
+ return (
301
+ <div className="nua-cadmin-editor">
302
+ <div className="nua-cadmin-editor-toolbar">
303
+ <StatusBadge status={status} />
304
+ <span className="nua-cadmin-spacer" />
305
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-ghost" onClick={() => void onRename()}>Rename</button>
306
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-danger" onClick={() => void onDelete()}>Delete</button>
307
+ </div>
308
+
309
+ {header.length > 0
310
+ ? (
311
+ <div className="nua-cadmin-editor-header-fields">
312
+ <FieldColumn fields={header} draft={draft} onField={onField} ctx={ctx} />
313
+ </div>
314
+ )
315
+ : null}
316
+
317
+ <div className="nua-cadmin-editor-grid">
318
+ <div className="nua-cadmin-editor-main">
319
+ <FieldColumn fields={main} draft={draft} onField={onField} ctx={ctx} />
320
+ {definition?.type !== 'data'
321
+ ? (
322
+ <div className="nua-cadmin-field">
323
+ <div className="nua-cadmin-field-label">
324
+ <span>Body</span>
325
+ <span className="nua-cadmin-field-type">markdown</span>
326
+ </div>
327
+ <textarea className="nua-cadmin-body-editor" value={draft.body} rows={16} onChange={e => onBody(e.target.value)} />
328
+ </div>
329
+ )
330
+ : null}
331
+ </div>
332
+ {sidebar.length > 0
333
+ ? (
334
+ <div className="nua-cadmin-editor-sidebar">
335
+ <FieldColumn fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
336
+ </div>
337
+ )
338
+ : null}
339
+ </div>
340
+
341
+ {status.kind === 'conflict'
342
+ ? <ConflictDialog onUseServer={resolveUseServer} onUseOurs={resolveUseOurs} onDismiss={dismissConflict} />
343
+ : null}
344
+ </div>
345
+ )
346
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Editable field widgets, driven entirely by a `FieldDefinition` (cms-headless F3.2).
3
+ *
4
+ * Each widget maps a `FieldType` to a control, reads/writes a *native* value
5
+ * (numbers, booleans, arrays, objects — see form-model), and reports changes via
6
+ * `onChange`. The renderer is recursive: `object` nests a group of widgets and
7
+ * `array` repeats the item widget. Media (`image`/`astroImage`) and `reference`
8
+ * widgets reach the sidecar through the injected `EditorContext`.
9
+ */
10
+
11
+ import type { FieldDefinition, FieldType } from '@nuasite/cms-types'
12
+ import { useCallback, useEffect, useState } from 'react'
13
+ import type { CmsClient } from './client'
14
+ import { blankValue, coerceInput, valueToArray, valueToBoolean, valueToInput, valueToObject } from './form-model'
15
+ import { MediaPicker } from './media-picker'
16
+
17
+ /** Cross-cutting services a widget may need (media uploads, reference lookups). */
18
+ export interface EditorContext {
19
+ client: CmsClient
20
+ /** The collection + slug being edited, used as upload context for media. */
21
+ collection: string
22
+ slug?: string
23
+ }
24
+
25
+ interface FieldEditorProps {
26
+ field: FieldDefinition
27
+ value: unknown
28
+ onChange: (value: unknown) => void
29
+ ctx: EditorContext
30
+ }
31
+
32
+ // ============================================================================
33
+ // Scalar widgets
34
+ // ============================================================================
35
+
36
+ /** HTML `<input type>` for the text-like scalar field types. */
37
+ const TEXT_INPUT_TYPE: Partial<Record<FieldType, string>> = {
38
+ url: 'url',
39
+ email: 'email',
40
+ tel: 'tel',
41
+ }
42
+
43
+ /** Native date/time control type per temporal field type. */
44
+ const DATE_INPUT_TYPE: Partial<Record<FieldType, string>> = {
45
+ date: 'date',
46
+ datetime: 'datetime-local',
47
+ time: 'time',
48
+ month: 'month',
49
+ }
50
+
51
+ function TextWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
52
+ const hints = field.hints
53
+ return (
54
+ <input
55
+ type={TEXT_INPUT_TYPE[field.type] ?? 'text'}
56
+ className="nua-cadmin-input"
57
+ value={valueToInput(value)}
58
+ placeholder={hints?.placeholder}
59
+ maxLength={hints?.maxLength}
60
+ minLength={hints?.minLength}
61
+ onChange={e => onChange(coerceInput(field.type, e.target.value))}
62
+ />
63
+ )
64
+ }
65
+
66
+ function TextareaWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
67
+ return (
68
+ <textarea
69
+ className="nua-cadmin-textarea"
70
+ value={valueToInput(value)}
71
+ rows={field.hints?.rows ?? 4}
72
+ placeholder={field.hints?.placeholder}
73
+ maxLength={field.hints?.maxLength}
74
+ onChange={e => onChange(e.target.value)}
75
+ />
76
+ )
77
+ }
78
+
79
+ function NumberWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
80
+ return (
81
+ <input
82
+ type="number"
83
+ className="nua-cadmin-input"
84
+ value={valueToInput(value)}
85
+ min={field.hints?.min}
86
+ max={field.hints?.max}
87
+ step={field.hints?.step}
88
+ placeholder={field.hints?.placeholder}
89
+ onChange={e => onChange(coerceInput('number', e.target.value))}
90
+ />
91
+ )
92
+ }
93
+
94
+ /** Year is a plain bounded number input (`<input type="year">` is not a thing). */
95
+ function YearWidget({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) {
96
+ return (
97
+ <input
98
+ type="number"
99
+ className="nua-cadmin-input"
100
+ value={valueToInput(value)}
101
+ min={1000}
102
+ max={9999}
103
+ step={1}
104
+ onChange={e => onChange(coerceInput('year', e.target.value))}
105
+ />
106
+ )
107
+ }
108
+
109
+ function DateWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
110
+ return (
111
+ <input
112
+ type={DATE_INPUT_TYPE[field.type] ?? 'date'}
113
+ className="nua-cadmin-input"
114
+ value={valueToInput(value)}
115
+ onChange={e => onChange(e.target.value)}
116
+ />
117
+ )
118
+ }
119
+
120
+ /**
121
+ * Boolean toggle. The `publish-toggle` role gets a pinned/styled appearance so
122
+ * the publish switch reads as a first-class control rather than a generic field.
123
+ */
124
+ function BooleanWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
125
+ const on = valueToBoolean(value)
126
+ const isPublish = field.role === 'publish-toggle'
127
+ return (
128
+ <button
129
+ type="button"
130
+ className={`nua-cadmin-toggle${on ? ' nua-cadmin-toggle-on' : ''}${isPublish ? ' nua-cadmin-toggle-publish' : ''}`}
131
+ role="switch"
132
+ aria-checked={on}
133
+ onClick={() => onChange(!on)}
134
+ >
135
+ <span className="nua-cadmin-toggle-knob" />
136
+ <span className="nua-cadmin-toggle-label">{on ? 'On' : 'Off'}</span>
137
+ </button>
138
+ )
139
+ }
140
+
141
+ function SelectWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
142
+ const options = field.options ?? []
143
+ const current = valueToInput(value)
144
+ return (
145
+ <select className="nua-cadmin-input" value={current} onChange={e => onChange(e.target.value)}>
146
+ {field.required ? null : <option value="">— none —</option>}
147
+ {options.map(opt => (
148
+ <option key={opt} value={opt}>
149
+ {opt}
150
+ </option>
151
+ ))}
152
+ {current !== '' && !options.includes(current) ? <option value={current}>{current} (current)</option> : null}
153
+ </select>
154
+ )
155
+ }
156
+
157
+ // ============================================================================
158
+ // Reference widget — picks an entry from the target collection.
159
+ // ============================================================================
160
+
161
+ function ReferenceWidget({ field, value, onChange, ctx }: FieldEditorProps) {
162
+ const target = field.collection
163
+ const [slugs, setSlugs] = useState<string[] | null>(null)
164
+ const [failed, setFailed] = useState(false)
165
+ const current = valueToInput(value)
166
+
167
+ useEffect(() => {
168
+ if (!target) return
169
+ let active = true
170
+ ctx.client.getEntries(target, { fields: 'slug,title', draft: 'all', limit: 200 }).then(
171
+ result => {
172
+ if (active) setSlugs(result.entries.map(e => e.slug))
173
+ },
174
+ () => {
175
+ if (active) setFailed(true)
176
+ },
177
+ )
178
+ return () => {
179
+ active = false
180
+ }
181
+ }, [ctx.client, target])
182
+
183
+ // No target collection, or the lookup failed → fall back to a free-text slug
184
+ // input rather than blocking the editor.
185
+ if (!target || failed) {
186
+ return <input type="text" className="nua-cadmin-input" value={current} placeholder="entry slug" onChange={e => onChange(e.target.value)} />
187
+ }
188
+ if (slugs === null) {
189
+ return <div className="nua-cadmin-field-loading">Loading {target}…</div>
190
+ }
191
+ return (
192
+ <select className="nua-cadmin-input" value={current} onChange={e => onChange(e.target.value)}>
193
+ <option value="">— none —</option>
194
+ {slugs.map(slug => (
195
+ <option key={slug} value={slug}>
196
+ {slug}
197
+ </option>
198
+ ))}
199
+ {current !== '' && !slugs.includes(current) ? <option value={current}>{current} (current)</option> : null}
200
+ </select>
201
+ )
202
+ }
203
+
204
+ // ============================================================================
205
+ // Array repeater — recurses the item widget.
206
+ // ============================================================================
207
+
208
+ function ArrayWidget({ field, value, onChange, ctx }: FieldEditorProps) {
209
+ const items = valueToArray(value)
210
+ const itemType: FieldType = field.itemType ?? 'text'
211
+ const itemField: FieldDefinition = {
212
+ name: field.name,
213
+ type: itemType,
214
+ required: false,
215
+ fields: field.fields,
216
+ options: field.options,
217
+ collection: field.collection,
218
+ }
219
+
220
+ const setItem = useCallback(
221
+ (index: number, next: unknown) => {
222
+ const copy = items.slice()
223
+ copy[index] = next
224
+ onChange(copy)
225
+ },
226
+ [items, onChange],
227
+ )
228
+ const removeItem = useCallback(
229
+ (index: number) => {
230
+ onChange(items.filter((_, i) => i !== index))
231
+ },
232
+ [items, onChange],
233
+ )
234
+ const addItem = useCallback(() => {
235
+ onChange([...items, blankValue(itemType)])
236
+ }, [items, itemType, onChange])
237
+
238
+ return (
239
+ <div className="nua-cadmin-array">
240
+ {items.length === 0 ? <div className="nua-cadmin-field-empty">No items.</div> : null}
241
+ {items.map((item, index) => (
242
+ // Array items have no stable id; positional index keys are correct here.
243
+ <div key={index} className="nua-cadmin-array-item">
244
+ <div className="nua-cadmin-array-item-body">
245
+ <FieldEditor field={itemField} value={item} onChange={next => setItem(index, next)} ctx={ctx} />
246
+ </div>
247
+ <button type="button" className="nua-cadmin-icon-btn" aria-label="Remove item" onClick={() => removeItem(index)}>
248
+ ×
249
+ </button>
250
+ </div>
251
+ ))}
252
+ <button type="button" className="nua-cadmin-add-btn" onClick={addItem}>
253
+ + Add item
254
+ </button>
255
+ </div>
256
+ )
257
+ }
258
+
259
+ // ============================================================================
260
+ // Nested object group.
261
+ // ============================================================================
262
+
263
+ function ObjectWidget({ field, value, onChange, ctx }: FieldEditorProps) {
264
+ const obj = valueToObject(value)
265
+ const subFields = field.fields ?? []
266
+ const setKey = useCallback(
267
+ (name: string, next: unknown) => {
268
+ onChange({ ...obj, [name]: next })
269
+ },
270
+ [obj, onChange],
271
+ )
272
+ if (subFields.length === 0) {
273
+ return <div className="nua-cadmin-field-empty">No nested fields.</div>
274
+ }
275
+ return (
276
+ <div className="nua-cadmin-object">
277
+ {subFields.map(sub => (
278
+ <div key={sub.name} className="nua-cadmin-object-field">
279
+ <div className="nua-cadmin-field-label">
280
+ <span>{sub.name}</span>
281
+ <span className="nua-cadmin-field-type">{sub.type}</span>
282
+ </div>
283
+ <FieldEditor field={sub} value={obj[sub.name]} onChange={next => setKey(sub.name, next)} ctx={ctx} />
284
+ </div>
285
+ ))}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ // ============================================================================
291
+ // Renderer
292
+ // ============================================================================
293
+
294
+ /**
295
+ * Render the editable control for `field`. Pure dispatch on `field.type`; the
296
+ * value is always native (coercion happens inside each widget on input).
297
+ */
298
+ export function FieldEditor({ field, value, onChange, ctx }: FieldEditorProps) {
299
+ switch (field.type) {
300
+ case 'text':
301
+ case 'url':
302
+ case 'email':
303
+ case 'tel':
304
+ case 'color':
305
+ return <TextWidget field={field} value={value} onChange={onChange} />
306
+ case 'textarea':
307
+ return <TextareaWidget field={field} value={value} onChange={onChange} />
308
+ case 'number':
309
+ return <NumberWidget field={field} value={value} onChange={onChange} />
310
+ case 'year':
311
+ return <YearWidget value={value} onChange={onChange} />
312
+ case 'date':
313
+ case 'datetime':
314
+ case 'time':
315
+ case 'month':
316
+ return <DateWidget field={field} value={value} onChange={onChange} />
317
+ case 'boolean':
318
+ return <BooleanWidget field={field} value={value} onChange={onChange} />
319
+ case 'select':
320
+ return <SelectWidget field={field} value={value} onChange={onChange} />
321
+ case 'image':
322
+ case 'file':
323
+ return (
324
+ <MediaPicker
325
+ client={ctx.client}
326
+ value={valueToInput(value)}
327
+ collection={ctx.collection}
328
+ entry={ctx.slug}
329
+ field={field.name}
330
+ accept={field.hints?.accept}
331
+ onChange={url => onChange(url)}
332
+ />
333
+ )
334
+ case 'reference':
335
+ return <ReferenceWidget field={field} value={value} onChange={onChange} ctx={ctx} />
336
+ case 'array':
337
+ return <ArrayWidget field={field} value={value} onChange={onChange} ctx={ctx} />
338
+ case 'object':
339
+ return <ObjectWidget field={field} value={value} onChange={onChange} ctx={ctx} />
340
+ }
341
+ }