@nuasite/collections-admin 0.43.0-beta.1 → 0.43.0-beta.2
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/dist/types/app.d.ts +6 -6
- package/dist/types/app.d.ts.map +1 -1
- package/dist/types/client.d.ts +78 -4
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/entry-create.d.ts +18 -0
- package/dist/types/entry-create.d.ts.map +1 -0
- package/dist/types/entry-editor.d.ts +30 -0
- package/dist/types/entry-editor.d.ts.map +1 -0
- package/dist/types/field-editor.d.ts +31 -0
- package/dist/types/field-editor.d.ts.map +1 -0
- package/dist/types/form-model.d.ts +61 -0
- package/dist/types/form-model.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/media-picker.d.ts +24 -0
- package/dist/types/media-picker.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/app.tsx +79 -66
- package/src/client.ts +217 -28
- package/src/entry-create.tsx +116 -0
- package/src/entry-editor.tsx +346 -0
- package/src/field-editor.tsx +341 -0
- package/src/form-model.ts +182 -0
- package/src/index.ts +12 -4
- package/src/media-picker.tsx +130 -0
- package/src/styles.css +381 -0
- package/src/field-view.tsx +0 -88
|
@@ -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
|
+
}
|