@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.
package/src/app.tsx CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
- * collections-admin SPA read-only milestone (cms-headless F3.1).
2
+ * collections-admin SPA (cms-headless F3.1 read-only + F3.2 editing).
3
3
  *
4
4
  * Host-agnostic: driven only by an `apiBase` prop, with internal view-state
5
- * navigation (list → entries → detail) via React state — never the host router.
6
- * That keeps the same component usable as a webmaster tab today and at
5
+ * navigation (list → entries → editor/create) via React state — never the host
6
+ * router. That keeps the same component usable as a webmaster tab today and at
7
7
  * `/_nua/admin` for local dev later (F7).
8
8
  *
9
- * Read-only: browse collections, list entries (sparse projection + cursor
10
- * pagination), and view a single entry's frontmatter + markdown body. Mutations
11
- * (editor/media/conflict) arrive in F3.2.
9
+ * Browse collections, list entries (sparse projection + cursor pagination), and
10
+ * edit an entry: a field→widget form built from `FieldDefinition[]` with debounced
11
+ * optimistic save and `409` conflict resolution, plus create/delete/rename flows.
12
12
  */
13
13
 
14
- import type { CollectionDefinition, CollectionEntry, CollectionEntryInfo, FieldDefinition } from '@nuasite/cms-types'
14
+ import type { CollectionDefinition, CollectionEntryInfo } from '@nuasite/cms-types'
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
16
  import { type CmsClient, CmsClientError, createClient } from './client'
17
- import { FieldRow } from './field-view'
17
+ import { EntryCreate } from './entry-create'
18
+ import { EntryEditor } from './entry-editor'
18
19
  import './styles.css'
19
20
 
20
21
  // ============================================================================
@@ -25,6 +26,7 @@ type View =
25
26
  | { view: 'list' }
26
27
  | { view: 'entries'; collection: string }
27
28
  | { view: 'detail'; collection: string; slug: string }
29
+ | { view: 'create'; collection: string }
28
30
 
29
31
  // ============================================================================
30
32
  // Shared async-load hook
@@ -134,7 +136,12 @@ function CollectionList({ client, onOpen }: { client: CmsClient; onOpen: (collec
134
136
  const ENTRIES_PAGE_SIZE = 50
135
137
  const ENTRIES_FIELDS = 'slug,title,draft,pathname'
136
138
 
137
- function EntriesTable({ client, collection, onOpen }: { client: CmsClient; collection: string; onOpen: (slug: string) => void }) {
139
+ function EntriesTable({ client, collection, onOpen, onCreate }: {
140
+ client: CmsClient
141
+ collection: string
142
+ onOpen: (slug: string) => void
143
+ onCreate: () => void
144
+ }) {
138
145
  const [rows, setRows] = useState<CollectionEntryInfo[]>([])
139
146
  const [cursor, setCursor] = useState<string | undefined>(undefined)
140
147
  const [hasMore, setHasMore] = useState(false)
@@ -176,10 +183,25 @@ function EntriesTable({ client, collection, onOpen }: { client: CmsClient; colle
176
183
 
177
184
  if (loading) return <Spinner label="Loading entries…" />
178
185
  if (error) return <ErrorState error={error} onRetry={() => void loadPage(undefined, false)} />
179
- if (rows.length === 0) return <EmptyState label="This collection has no entries." />
186
+
187
+ const toolbar = (
188
+ <div className="nua-cadmin-entries-toolbar">
189
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-primary" onClick={onCreate}>+ New entry</button>
190
+ </div>
191
+ )
192
+
193
+ if (rows.length === 0) {
194
+ return (
195
+ <div>
196
+ {toolbar}
197
+ <EmptyState label="This collection has no entries." />
198
+ </div>
199
+ )
200
+ }
180
201
 
181
202
  return (
182
203
  <div>
204
+ {toolbar}
183
205
  <table className="nua-cadmin-table">
184
206
  <thead>
185
207
  <tr>
@@ -211,57 +233,6 @@ function EntriesTable({ client, collection, onOpen }: { client: CmsClient; colle
211
233
  )
212
234
  }
213
235
 
214
- // ============================================================================
215
- // Entry detail
216
- // ============================================================================
217
-
218
- /**
219
- * Order the collection's fields for display: `publish-toggle`/`publish-date`
220
- * roles and `sidebar`/`header` positioned fields first, then the rest in schema
221
- * order. Hidden fields are dropped.
222
- */
223
- function orderFields(fields: FieldDefinition[]): FieldDefinition[] {
224
- const visible = fields.filter((f) => !f.hidden)
225
- const pinned = visible.filter((f) => f.role !== undefined || f.position !== undefined)
226
- const rest = visible.filter((f) => f.role === undefined && f.position === undefined)
227
- return [...pinned, ...rest]
228
- }
229
-
230
- function EntryDetail({ client, collections, collection, slug }: {
231
- client: CmsClient
232
- collections: CollectionDefinition[]
233
- collection: string
234
- slug: string
235
- }) {
236
- const { data, error, loading, reload } = useAsync<CollectionEntry>(() => client.getEntry(collection, slug), [client, collection, slug])
237
-
238
- const def = useMemo(() => collections.find((c) => c.name === collection), [collections, collection])
239
-
240
- if (loading) return <Spinner label="Loading entry…" />
241
- if (error) return <ErrorState error={error} onRetry={reload} />
242
- if (!data) return <EmptyState label="Entry not found." />
243
-
244
- const fieldDefs = def ? orderFields(def.fields) : []
245
- const renderedNames = new Set(fieldDefs.map((f) => f.name))
246
- // Frontmatter keys present on the entry but absent from the inferred schema.
247
- const extraKeys = Object.keys(data.frontmatter).filter((k) => !renderedNames.has(k))
248
-
249
- return (
250
- <div>
251
- <div className="nua-cadmin-fields">
252
- {fieldDefs.length === 0 && extraKeys.length === 0 ? <EmptyState label="No frontmatter fields." /> : null}
253
- {fieldDefs.map((field) => <FieldRow key={field.name} field={field} raw={data.frontmatter[field.name]?.value} />)}
254
- {extraKeys.map((key) => <FieldRow key={key} field={{ name: key, type: 'text', required: false }} raw={data.frontmatter[key]?.value} />)}
255
- </div>
256
-
257
- <h3 className="nua-cadmin-section-title">Body</h3>
258
- {data.body.trim() === ''
259
- ? <EmptyState label="This entry has no markdown body." />
260
- : <pre className="nua-cadmin-body-content">{data.body}</pre>}
261
- </div>
262
- )
263
- }
264
-
265
236
  // ============================================================================
266
237
  // Root
267
238
  // ============================================================================
@@ -280,25 +251,28 @@ export function CollectionsAdminApp({ apiBase, onClose }: CollectionsAdminAppPro
280
251
  const client = useMemo(() => createClient(apiBase), [apiBase])
281
252
  const [state, setState] = useState<View>({ view: 'list' })
282
253
 
283
- // The collection definitions are needed by the detail view to drive field
284
- // rendering; load them once at the root and pass down.
254
+ // The collection definitions drive the editor's field rendering; load them once
255
+ // at the root and pass the active one down.
285
256
  const collectionsState = useAsync(() => client.getCollections(), [client])
286
257
  const collections = collectionsState.data ?? []
287
258
 
288
259
  const goList = useCallback(() => setState({ view: 'list' }), [])
289
260
  const goEntries = useCallback((collection: string) => setState({ view: 'entries', collection }), [])
290
261
  const goDetail = useCallback((collection: string, slug: string) => setState({ view: 'detail', collection, slug }), [])
262
+ const goCreate = useCallback((collection: string) => setState({ view: 'create', collection }), [])
291
263
 
292
264
  const activeCollection = state.view !== 'list'
293
265
  ? collections.find((c) => c.name === state.collection)
294
266
  : undefined
295
267
  const collectionLabel = activeCollection ? (activeCollection.label || activeCollection.name) : (state.view !== 'list' ? state.collection : '')
296
268
 
269
+ const isEntryView = state.view === 'detail' || state.view === 'create'
270
+
297
271
  return (
298
272
  <div className="nua-cadmin">
299
273
  <div className="nua-cadmin-header">
300
274
  {state.view === 'entries' ? <button type="button" className="nua-cadmin-back" onClick={goList}>← Collections</button> : null}
301
- {state.view === 'detail'
275
+ {isEntryView
302
276
  ? <button type="button" className="nua-cadmin-back" onClick={() => goEntries(state.collection)}>← {collectionLabel}</button>
303
277
  : null}
304
278
 
@@ -312,6 +286,14 @@ export function CollectionsAdminApp({ apiBase, onClose }: CollectionsAdminAppPro
312
286
  </h2>
313
287
  )
314
288
  : null}
289
+ {state.view === 'create'
290
+ ? (
291
+ <h2 className="nua-cadmin-title">
292
+ {collectionLabel}
293
+ <span className="nua-cadmin-crumb">/ new entry</span>
294
+ </h2>
295
+ )
296
+ : null}
315
297
 
316
298
  <span className="nua-cadmin-spacer" />
317
299
  {onClose ? <button type="button" className="nua-cadmin-close" aria-label="Close" onClick={onClose}>×</button> : null}
@@ -320,9 +302,40 @@ export function CollectionsAdminApp({ apiBase, onClose }: CollectionsAdminAppPro
320
302
  <div className="nua-cadmin-body">
321
303
  {state.view === 'list' ? <CollectionList client={client} onOpen={goEntries} /> : null}
322
304
  {state.view === 'entries'
323
- ? <EntriesTable client={client} collection={state.collection} onOpen={(slug) => goDetail(state.collection, slug)} />
305
+ ? (
306
+ <EntriesTable
307
+ client={client}
308
+ collection={state.collection}
309
+ onOpen={(slug) => goDetail(state.collection, slug)}
310
+ onCreate={() => goCreate(state.collection)}
311
+ />
312
+ )
313
+ : null}
314
+ {state.view === 'detail'
315
+ ? (
316
+ <EntryEditor
317
+ // Remount the editor when the slug changes so its draft/baseHash reset cleanly.
318
+ key={`${state.collection}/${state.slug}`}
319
+ client={client}
320
+ definition={activeCollection}
321
+ collection={state.collection}
322
+ slug={state.slug}
323
+ onDeleted={() => goEntries(state.collection)}
324
+ onRenamed={(newSlug) => goDetail(state.collection, newSlug)}
325
+ />
326
+ )
327
+ : null}
328
+ {state.view === 'create'
329
+ ? (
330
+ <EntryCreate
331
+ client={client}
332
+ definition={activeCollection}
333
+ collection={state.collection}
334
+ onCreated={(slug) => goDetail(state.collection, slug)}
335
+ onCancel={() => goEntries(state.collection)}
336
+ />
337
+ )
324
338
  : null}
325
- {state.view === 'detail' ? <EntryDetail client={client} collections={collections} collection={state.collection} slug={state.slug} /> : null}
326
339
  </div>
327
340
  </div>
328
341
  )
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Typed read-only client over the cms-sidecar `/cms/v1` HTTP contract.
2
+ * Typed client over the cms-sidecar `/cms/v1` HTTP contract (reads + mutations).
3
3
  *
4
4
  * The host (webmaster BFF, or a local dev proxy in F7) mounts the sidecar under
5
5
  * an `apiBase` and adds the `/cms/v1` prefix itself — so this client requests
@@ -7,11 +7,22 @@
7
7
  *
8
8
  * The structural model (collections/entries/fields) is reused 1:1 from
9
9
  * `@nuasite/cms-types`. The thin HTTP envelope (project model, sparse entries
10
- * list, error codes) mirrors the sidecar's wire types; it is declared here
11
- * because those types are not part of the `@nuasite/cms-types` contract surface.
10
+ * list, error codes, mutation bodies, conflict response) mirrors the sidecar's
11
+ * wire types; it is declared here because those types are not part of the
12
+ * `@nuasite/cms-types` contract surface.
12
13
  */
13
14
 
14
- import type { CollectionDefinition, CollectionEntry, CollectionEntryInfo } from '@nuasite/cms-types'
15
+ import type {
16
+ CollectionDefinition,
17
+ CollectionEntry,
18
+ CollectionEntryInfo,
19
+ MediaListResult,
20
+ MediaUploadResult,
21
+ MutationResult,
22
+ } from '@nuasite/cms-types'
23
+
24
+ /** HTTP status the sidecar uses for an optimistic-concurrency conflict. */
25
+ const STATUS_CONFLICT = 409
15
26
 
16
27
  // ============================================================================
17
28
  // Wire envelope (mirrors @nuasite/cms-sidecar's `/cms/v1` contract)
@@ -60,6 +71,53 @@ export interface CmsEntriesListResult {
60
71
  hasMore: boolean
61
72
  }
62
73
 
74
+ /**
75
+ * `409` body for a `PATCH` whose `baseHash` no longer matches disk (an agent or a
76
+ * human wrote in between). Carries the current server version so the UI can offer
77
+ * "use server" vs "use ours". Mirrors the sidecar `ConflictResponse`.
78
+ */
79
+ export interface CmsConflict {
80
+ code: 'conflict'
81
+ serverHash: string
82
+ /** Raw (non-stringified) server frontmatter — unlike the line-keyed GET-detail shape. */
83
+ serverFrontmatter: Record<string, unknown>
84
+ serverBody?: string
85
+ }
86
+
87
+ /** `PATCH …/entries/:slug` — frontmatter keys are merged (not replaced). */
88
+ export interface UpdateEntryInput {
89
+ frontmatter?: Record<string, unknown>
90
+ body?: string
91
+ /** Hash of the source the client edited; drives optimistic concurrency. */
92
+ baseHash?: string
93
+ }
94
+
95
+ export interface CreateEntryInput {
96
+ slug: string
97
+ frontmatter: Record<string, unknown>
98
+ body?: string
99
+ /** File extension override for data collections (e.g. 'json', 'yaml'). */
100
+ fileExtension?: string
101
+ }
102
+
103
+ /** Context passed to media operations so uploads can be filed against an entry/field. */
104
+ export interface MediaContext {
105
+ collection?: string
106
+ entry?: string
107
+ field?: string
108
+ /** Subfolder under the media root. */
109
+ folder?: string
110
+ }
111
+
112
+ /**
113
+ * Either a successful `MutationResult` or a `409` conflict the caller must
114
+ * resolve. Returned (not thrown) by `updateEntry` so the editor can branch
115
+ * without exception flow.
116
+ */
117
+ export type UpdateEntryResult =
118
+ | { status: 'ok'; result: MutationResult }
119
+ | { status: 'conflict'; conflict: CmsConflict }
120
+
63
121
  // ============================================================================
64
122
  // Client error
65
123
  // ============================================================================
@@ -113,12 +171,21 @@ export interface GetEntriesOptions {
113
171
  // ============================================================================
114
172
 
115
173
  function isApiError(value: unknown): value is CmsApiError {
116
- return typeof value === 'object'
117
- && value !== null
118
- && 'error' in value
119
- && typeof (value as { error: unknown }).error === 'string'
120
- && 'code' in value
121
- && typeof (value as { code: unknown }).code === 'string'
174
+ return isRecord(value)
175
+ && typeof value.error === 'string'
176
+ && typeof value.code === 'string'
177
+ }
178
+
179
+ /** Narrow `unknown` to a record so property reads typecheck without casts. */
180
+ function isRecord(value: unknown): value is Record<string, unknown> {
181
+ return typeof value === 'object' && value !== null
182
+ }
183
+
184
+ function isConflict(value: unknown): value is CmsConflict {
185
+ if (!isRecord(value)) return false
186
+ return value.code === 'conflict'
187
+ && typeof value.serverHash === 'string'
188
+ && isRecord(value.serverFrontmatter)
122
189
  }
123
190
 
124
191
  const KNOWN_ERROR_CODES: readonly CmsErrorCode[] = [
@@ -140,6 +207,35 @@ export interface CmsClient {
140
207
  getCollections(): Promise<CollectionDefinition[]>
141
208
  getEntries(collection: string, options?: GetEntriesOptions): Promise<CmsEntriesListResult>
142
209
  getEntry(collection: string, slug: string): Promise<CollectionEntry>
210
+
211
+ // --- Mutations ---
212
+
213
+ /**
214
+ * Merge-patch an entry's frontmatter/body. Returns a discriminated result: a
215
+ * `409` is surfaced as `{ status: 'conflict' }` (not thrown) so the editor can
216
+ * open the conflict dialog. The new `baseHash` is on `result.sourceHash`.
217
+ */
218
+ updateEntry(collection: string, slug: string, input: UpdateEntryInput): Promise<UpdateEntryResult>
219
+ createEntry(collection: string, input: CreateEntryInput): Promise<MutationResult>
220
+ deleteEntry(collection: string, slug: string): Promise<MutationResult>
221
+ renameEntry(collection: string, slug: string, to: string): Promise<MutationResult>
222
+ addArrayItem(collection: string, slug: string, field: string, value: unknown, index?: number): Promise<MutationResult>
223
+ removeArrayItem(collection: string, slug: string, field: string, index: number): Promise<MutationResult>
224
+
225
+ // --- Media (degrades gracefully when the sidecar has no adapter wired: 501). ---
226
+
227
+ listMedia(options?: { folder?: string; cursor?: string; limit?: number }): Promise<MediaListResult>
228
+ uploadMedia(file: File, context?: MediaContext): Promise<MediaUploadResult>
229
+ deleteMedia(id: string): Promise<{ success: boolean; error?: string }>
230
+ }
231
+
232
+ /**
233
+ * Whether a thrown `CmsClientError` means "media is not available" — the deployed
234
+ * sidecar may have no media adapter wired (`501 unsupported`). The picker uses
235
+ * this to degrade gracefully instead of surfacing a hard error.
236
+ */
237
+ export function isMediaUnavailable(error: unknown): boolean {
238
+ return error instanceof CmsClientError && (error.status === 501 || error.code === 'unsupported')
143
239
  }
144
240
 
145
241
  export function createClient(apiBase: string): CmsClient {
@@ -164,32 +260,63 @@ export function createClient(apiBase: string): CmsClient {
164
260
 
165
261
  async function toError(response: Response): Promise<CmsClientError> {
166
262
  // 403 is produced by the BFF (project scope), not the sidecar, so it has no
167
- // sidecar `code`; surface it as a distinct `forbidden`.
168
- if (response.status === 403) {
169
- const message = await readErrorMessage(response, 'You do not have access to this project.')
170
- return new CmsClientError(403, 'forbidden', message)
263
+ // sidecar `code`; `errorFromBody` surfaces it as a distinct `forbidden`.
264
+ const body: unknown = await response.json().catch(() => null)
265
+ return errorFromBody(response.status, body)
266
+ }
267
+
268
+ function errorMessageFromBody(body: unknown, fallback: string): string {
269
+ if (isApiError(body)) return body.error
270
+ if (isRecord(body)) {
271
+ const err = body.error
272
+ if (isRecord(err) && typeof err.message === 'string') return err.message
171
273
  }
274
+ return fallback
275
+ }
172
276
 
173
- const body: unknown = await response.json().catch(() => null)
277
+ /** Build a `CmsClientError` from an already-parsed body (no re-read of the stream). */
278
+ function errorFromBody(status: number, body: unknown): CmsClientError {
279
+ if (status === 403) {
280
+ return new CmsClientError(403, 'forbidden', errorMessageFromBody(body, 'You do not have access to this project.'))
281
+ }
174
282
  if (isApiError(body) && isErrorCode(body.code)) {
175
- return new CmsClientError(response.status, body.code, body.error)
283
+ return new CmsClientError(status, body.code, body.error)
176
284
  }
177
- if (response.status === 401) {
285
+ if (status === 401) {
178
286
  return new CmsClientError(401, 'unauthorized', 'Your session has expired. Please reload.')
179
287
  }
180
- return new CmsClientError(response.status, 'unknown', `Request failed (${response.status})`)
288
+ return new CmsClientError(status, 'unknown', errorMessageFromBody(body, `Request failed (${status})`))
181
289
  }
182
290
 
183
- async function readErrorMessage(response: Response, fallback: string): Promise<string> {
184
- const body: unknown = await response.json().catch(() => null)
185
- if (isApiError(body)) return body.error
186
- if (typeof body === 'object' && body !== null && 'error' in body) {
187
- const err = (body as { error: unknown }).error
188
- if (typeof err === 'object' && err !== null && 'message' in err && typeof (err as { message: unknown }).message === 'string') {
189
- return (err as { message: string }).message
190
- }
291
+ function mutationInit(method: string, body?: unknown): RequestInit {
292
+ const init: RequestInit = {
293
+ method,
294
+ credentials: 'include',
295
+ headers: { accept: 'application/json' },
191
296
  }
192
- return fallback
297
+ if (body !== undefined) {
298
+ init.body = JSON.stringify(body)
299
+ init.headers = { accept: 'application/json', 'content-type': 'application/json' }
300
+ }
301
+ return init
302
+ }
303
+
304
+ /**
305
+ * Send a JSON-body mutation (POST/PATCH/DELETE). Throws `CmsClientError` on any
306
+ * non-2xx — used by the mutations that have no conflict branch. The
307
+ * conflict-aware update has its own path below.
308
+ */
309
+ async function mutate<T>(path: string, method: string, body?: unknown): Promise<T> {
310
+ const response = await fetch(`${base}${path}`, mutationInit(method, body))
311
+ if (!response.ok) throw await toError(response)
312
+ // Mutation responses are documented JSON; the asserted shape is trusted
313
+ // (`response.json()` widens to the declared `T`, mirroring `request`).
314
+ const value: T = await response.json()
315
+ return value
316
+ }
317
+
318
+ function entryPath(collection: string, slug: string): string {
319
+ return `/collections/${encodeURIComponent(collection)}/entries/${encodeURIComponent(slug)}`
193
320
  }
194
321
 
195
322
  return {
@@ -210,7 +337,69 @@ export function createClient(apiBase: string): CmsClient {
210
337
  return request<CmsEntriesListResult>(`/collections/${encodeURIComponent(collection)}/entries${suffix}`)
211
338
  },
212
339
  getEntry(collection, slug) {
213
- return request<CollectionEntry>(`/collections/${encodeURIComponent(collection)}/entries/${encodeURIComponent(slug)}`)
340
+ return request<CollectionEntry>(entryPath(collection, slug))
341
+ },
342
+
343
+ async updateEntry(collection, slug, input) {
344
+ const response = await fetch(`${base}${entryPath(collection, slug)}`, mutationInit('PATCH', input))
345
+ // A `409` carries the server version; parse and return it for the dialog.
346
+ if (response.status === STATUS_CONFLICT) {
347
+ const body: unknown = await response.json().catch(() => null)
348
+ if (isConflict(body)) return { status: 'conflict', conflict: body }
349
+ throw errorFromBody(response.status, body)
350
+ }
351
+ if (!response.ok) throw await toError(response)
352
+ const result: MutationResult = await response.json()
353
+ return { status: 'ok', result }
354
+ },
355
+ createEntry(collection, input) {
356
+ return mutate<MutationResult>(`/collections/${encodeURIComponent(collection)}/entries`, 'POST', input)
357
+ },
358
+ deleteEntry(collection, slug) {
359
+ return mutate<MutationResult>(entryPath(collection, slug), 'DELETE')
360
+ },
361
+ renameEntry(collection, slug, to) {
362
+ return mutate<MutationResult>(`${entryPath(collection, slug)}/rename`, 'POST', { to })
363
+ },
364
+ addArrayItem(collection, slug, field, value, index) {
365
+ const body = index === undefined ? { field, value } : { field, value, index }
366
+ return mutate<MutationResult>(`${entryPath(collection, slug)}/array`, 'POST', body)
367
+ },
368
+ removeArrayItem(collection, slug, field, index) {
369
+ return mutate<MutationResult>(`${entryPath(collection, slug)}/array`, 'DELETE', { field, index })
370
+ },
371
+
372
+ listMedia(options = {}) {
373
+ const params = new URLSearchParams()
374
+ if (options.folder !== undefined) params.set('folder', options.folder)
375
+ if (options.cursor !== undefined) params.set('cursor', options.cursor)
376
+ if (options.limit !== undefined) params.set('limit', String(options.limit))
377
+ const query = params.toString()
378
+ return request<MediaListResult>(`/media${query === '' ? '' : `?${query}`}`)
379
+ },
380
+ async uploadMedia(file, context = {}) {
381
+ // The sidecar reads upload context (collection/entry/field/folder) from the
382
+ // query string; the file rides in multipart form data under `file`.
383
+ const params = new URLSearchParams()
384
+ if (context.collection !== undefined) params.set('collection', context.collection)
385
+ if (context.entry !== undefined) params.set('entry', context.entry)
386
+ if (context.field !== undefined) params.set('field', context.field)
387
+ if (context.folder !== undefined) params.set('folder', context.folder)
388
+ const query = params.toString()
389
+ const form = new FormData()
390
+ form.append('file', file)
391
+ const response = await fetch(`${base}/media${query === '' ? '' : `?${query}`}`, {
392
+ method: 'POST',
393
+ credentials: 'include',
394
+ headers: { accept: 'application/json' },
395
+ body: form,
396
+ })
397
+ if (!response.ok) throw await toError(response)
398
+ const result: MediaUploadResult = await response.json()
399
+ return result
400
+ },
401
+ deleteMedia(id) {
402
+ return mutate<{ success: boolean; error?: string }>(`/media/${encodeURIComponent(id)}`, 'DELETE')
214
403
  },
215
404
  }
216
405
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Create-entry form built from a collection's `FieldDefinition[]` (cms-headless F3.2).
3
+ *
4
+ * A required `slug` plus a field for each non-hidden definition (seeded from
5
+ * `defaultValue`). On submit it `POST`s the new entry and hands the created slug
6
+ * back to the host so it can open the editor. Reuses the same field widgets and
7
+ * native draft model as the editor.
8
+ */
9
+
10
+ import type { CollectionDefinition, FieldDefinition } from '@nuasite/cms-types'
11
+ import { useCallback, useMemo, useState } from 'react'
12
+ import { type CmsClient, CmsClientError } from './client'
13
+ import { type EditorContext, FieldEditor } from './field-editor'
14
+ import { draftForCreate, type EntryDraft, setDraftField } from './form-model'
15
+
16
+ function visibleFields(fields: FieldDefinition[]): FieldDefinition[] {
17
+ return fields.filter(f => !f.hidden)
18
+ }
19
+
20
+ export function EntryCreate({ client, definition, collection, onCreated, onCancel }: {
21
+ client: CmsClient
22
+ definition: CollectionDefinition | undefined
23
+ collection: string
24
+ onCreated: (slug: string) => void
25
+ onCancel: () => void
26
+ }) {
27
+ const fields = useMemo(() => definition?.fields ?? [], [definition])
28
+ const [slug, setSlug] = useState('')
29
+ const [draft, setDraft] = useState<EntryDraft>(() => draftForCreate(fields))
30
+ const [submitting, setSubmitting] = useState(false)
31
+ const [error, setError] = useState<string | null>(null)
32
+
33
+ const ctx: EditorContext = useMemo(() => ({ client, collection }), [client, collection])
34
+
35
+ const onField = useCallback((name: string, value: unknown) => {
36
+ setDraft(prev => setDraftField(prev, name, value))
37
+ }, [])
38
+
39
+ const submit = useCallback(async () => {
40
+ const trimmed = slug.trim()
41
+ if (trimmed === '') {
42
+ setError('A slug is required.')
43
+ return
44
+ }
45
+ setSubmitting(true)
46
+ setError(null)
47
+ try {
48
+ const result = await client.createEntry(collection, {
49
+ slug: trimmed,
50
+ frontmatter: draft.frontmatter,
51
+ body: draft.body,
52
+ fileExtension: definition?.fileExtension,
53
+ })
54
+ if (result.success) {
55
+ onCreated(trimmed)
56
+ } else {
57
+ setError(result.error ?? 'Could not create the entry.')
58
+ }
59
+ } catch (err: unknown) {
60
+ setError(err instanceof CmsClientError ? err.message : 'Could not create the entry.')
61
+ } finally {
62
+ setSubmitting(false)
63
+ }
64
+ }, [client, collection, definition, draft, slug, onCreated])
65
+
66
+ const isData = definition?.type === 'data'
67
+
68
+ return (
69
+ <div className="nua-cadmin-editor">
70
+ <div className="nua-cadmin-field">
71
+ <div className="nua-cadmin-field-label">
72
+ <span>slug</span>
73
+ <span className="nua-cadmin-field-type">text · required</span>
74
+ </div>
75
+ <input type="text" className="nua-cadmin-input" value={slug} placeholder="my-new-entry" onChange={e => setSlug(e.target.value)} />
76
+ </div>
77
+
78
+ {visibleFields(fields).map(field => (
79
+ <div key={field.name} className={`nua-cadmin-field${field.role ? ` nua-cadmin-field-${field.role}` : ''}`}>
80
+ <div className="nua-cadmin-field-label">
81
+ <span>{field.name}</span>
82
+ <span className="nua-cadmin-field-type">{field.type}{field.required ? ' · required' : ''}</span>
83
+ </div>
84
+ <FieldEditor field={field} value={draft.frontmatter[field.name]} onChange={value => onField(field.name, value)} ctx={ctx} />
85
+ </div>
86
+ ))}
87
+
88
+ {!isData
89
+ ? (
90
+ <div className="nua-cadmin-field">
91
+ <div className="nua-cadmin-field-label">
92
+ <span>Body</span>
93
+ <span className="nua-cadmin-field-type">markdown</span>
94
+ </div>
95
+ <textarea
96
+ className="nua-cadmin-body-editor"
97
+ value={draft.body}
98
+ rows={10}
99
+ onChange={e => setDraft(prev => ({ ...prev, body: e.target.value }))}
100
+ />
101
+ </div>
102
+ )
103
+ : null}
104
+
105
+ {error ? <div className="nua-cadmin-media-error">{error}</div> : null}
106
+
107
+ <div className="nua-cadmin-editor-toolbar">
108
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-ghost" disabled={submitting} onClick={onCancel}>Cancel</button>
109
+ <span className="nua-cadmin-spacer" />
110
+ <button type="button" className="nua-cadmin-btn nua-cadmin-btn-primary" disabled={submitting} onClick={() => void submit()}>
111
+ {submitting ? 'Creating…' : 'Create entry'}
112
+ </button>
113
+ </div>
114
+ </div>
115
+ )
116
+ }