@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/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/dist/types/app.js +0 -240
- package/dist/types/client.js +0 -134
- package/dist/types/field-view.d.ts +0 -17
- package/dist/types/field-view.d.ts.map +0 -1
- package/dist/types/field-view.js +0 -77
- package/dist/types/index.js +0 -8
- package/src/field-view.tsx +0 -88
package/src/app.tsx
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* collections-admin SPA
|
|
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 →
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,
|
|
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 {
|
|
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 }: {
|
|
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
|
-
|
|
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
|
|
284
|
-
//
|
|
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
|
-
{
|
|
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
|
-
?
|
|
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
|
|
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
|
|
11
|
-
* because those types are not part of the
|
|
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 {
|
|
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
|
|
117
|
-
&& value
|
|
118
|
-
&& '
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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`;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
283
|
+
return new CmsClientError(status, body.code, body.error)
|
|
176
284
|
}
|
|
177
|
-
if (
|
|
285
|
+
if (status === 401) {
|
|
178
286
|
return new CmsClientError(401, 'unauthorized', 'Your session has expired. Please reload.')
|
|
179
287
|
}
|
|
180
|
-
return new CmsClientError(
|
|
288
|
+
return new CmsClientError(status, 'unknown', errorMessageFromBody(body, `Request failed (${status})`))
|
|
181
289
|
}
|
|
182
290
|
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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>(
|
|
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
|
+
}
|