@nuasite/collections-admin 0.43.0-beta.3 → 0.43.0-beta.8
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 +11 -6
- package/dist/types/app.d.ts.map +1 -1
- package/dist/types/entry-create.d.ts +1 -1
- package/dist/types/entry-create.d.ts.map +1 -1
- package/dist/types/entry-editor.d.ts +1 -1
- package/dist/types/entry-editor.d.ts.map +1 -1
- package/dist/types/field-editor.d.ts +1 -1
- package/dist/types/field-editor.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/media-picker.d.ts +1 -1
- package/dist/types/media-picker.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -2
- package/src/app.tsx +182 -110
- package/src/entry-create.tsx +1 -2
- package/src/entry-editor.tsx +233 -39
- package/src/field-editor.tsx +55 -7
- package/src/index.ts +4 -2
- package/src/media-picker.tsx +17 -3
- package/src/styles.css +220 -1
- package/src/tsconfig.json +2 -0
- package/dist/types/client.d.ts +0 -149
- package/dist/types/client.d.ts.map +0 -1
- package/dist/types/form-model.d.ts +0 -61
- package/dist/types/form-model.d.ts.map +0 -1
- package/src/client.ts +0 -405
- package/src/form-model.ts +0 -182
package/src/entry-editor.tsx
CHANGED
|
@@ -18,12 +18,19 @@
|
|
|
18
18
|
* + `serverHash`; "use ours" re-PATCHes with `baseHash = serverHash` (force-over).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import
|
|
21
|
+
import {
|
|
22
|
+
type CmsClient,
|
|
23
|
+
CmsClientError,
|
|
24
|
+
type CmsConflict,
|
|
25
|
+
draftFromEntry,
|
|
26
|
+
draftFromServerFrontmatter,
|
|
27
|
+
type EntryDraft,
|
|
28
|
+
setDraftField,
|
|
29
|
+
} from '@nuasite/cms-client'
|
|
30
|
+
import { MdxBodyEditor } from '@nuasite/cms-mdx-editor'
|
|
31
|
+
import type { CollectionDefinition, ComponentDefinition, FieldDefinition } from '@nuasite/cms-types'
|
|
22
32
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
23
|
-
import type { CmsClient, CmsConflict } from './client'
|
|
24
|
-
import { CmsClientError } from './client'
|
|
25
33
|
import { type EditorContext, FieldEditor } from './field-editor'
|
|
26
|
-
import { draftFromEntry, draftFromServerFrontmatter, type EntryDraft, setDraftField } from './form-model'
|
|
27
34
|
|
|
28
35
|
const SAVE_DEBOUNCE_MS = 700
|
|
29
36
|
|
|
@@ -34,53 +41,215 @@ type SaveStatus =
|
|
|
34
41
|
| { kind: 'conflict'; conflict: CmsConflict }
|
|
35
42
|
| { kind: 'error'; message: string }
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Layout resolution — sidebar + ordered sections (tabs / stacked)
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
interface RenderSection {
|
|
49
|
+
title?: string
|
|
50
|
+
fields: FieldDefinition[]
|
|
51
|
+
collapsed?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RenderLayout {
|
|
38
55
|
header: FieldDefinition[]
|
|
39
56
|
sidebar: FieldDefinition[]
|
|
40
|
-
|
|
57
|
+
sections: RenderSection[]
|
|
58
|
+
display: 'sections' | 'tabs'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Above this many main sections, default to tabs instead of a long stack. */
|
|
62
|
+
const AUTO_TAB_THRESHOLD = 4
|
|
63
|
+
|
|
64
|
+
const fieldLabel = (f: FieldDefinition): string => f.label ?? f.name
|
|
65
|
+
/** Prettify a raw field name for a section title (e.g. `pricing_tiers` → `Pricing tiers`). */
|
|
66
|
+
const prettify = (name: string): string => {
|
|
67
|
+
const spaced = name.replace(/[_-]+/g, ' ').trim()
|
|
68
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const roleRank = (f: FieldDefinition): number => (f.role === 'publish-toggle' ? 0 : f.role === 'publish-date' ? 1 : 2)
|
|
72
|
+
|
|
73
|
+
/** Stable sort by explicit `order` (unset → keeps schema order). */
|
|
74
|
+
function sortByOrder(fields: FieldDefinition[]): FieldDefinition[] {
|
|
75
|
+
return fields
|
|
76
|
+
.map((f, i) => [f, i] as const)
|
|
77
|
+
.sort((a, b) => (a[0].order ?? 0) - (b[0].order ?? 0) || a[1] - b[1])
|
|
78
|
+
.map(([f]) => f)
|
|
41
79
|
}
|
|
42
80
|
|
|
43
81
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
82
|
+
* Default sections when no `cms.sections` are declared: scalar fields group by
|
|
83
|
+
* `group` (consecutive same-group fields share a section; ungrouped → an untitled
|
|
84
|
+
* section), and each nested `object`/`array` field becomes its own collapsible
|
|
85
|
+
* section — so a big schema reads as discrete blocks rather than one long column.
|
|
46
86
|
*/
|
|
47
|
-
function
|
|
48
|
-
|
|
87
|
+
function deriveDefaultSections(main: FieldDefinition[]): RenderSection[] {
|
|
88
|
+
// Scalars are consolidated (ungrouped → one section, else one per `group`, in
|
|
89
|
+
// first-appearance order); each nested object/array becomes its own collapsible
|
|
90
|
+
// section, appended after the scalars — so the tab/section list stays clean
|
|
91
|
+
// instead of interleaving repeated "General" buckets.
|
|
92
|
+
const scalarSections: RenderSection[] = []
|
|
93
|
+
const byGroup = new Map<string, RenderSection>()
|
|
94
|
+
const structural: RenderSection[] = []
|
|
95
|
+
for (const f of main) {
|
|
96
|
+
if (f.type === 'object' || f.type === 'array') {
|
|
97
|
+
structural.push({ title: f.label ?? prettify(f.name), fields: [f], collapsed: true })
|
|
98
|
+
} else {
|
|
99
|
+
const key = f.group ?? ''
|
|
100
|
+
let section = byGroup.get(key)
|
|
101
|
+
if (!section) {
|
|
102
|
+
section = { title: f.group, fields: [] }
|
|
103
|
+
byGroup.set(key, section)
|
|
104
|
+
scalarSections.push(section)
|
|
105
|
+
}
|
|
106
|
+
section.fields.push(f)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...scalarSections, ...structural]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build the editor's render plan from the collection's declarative layout (if any) + heuristics. */
|
|
113
|
+
function resolveLayout(definition: CollectionDefinition | undefined, fields: FieldDefinition[]): RenderLayout {
|
|
114
|
+
const visible = sortByOrder(fields.filter(f => !f.hidden))
|
|
49
115
|
const header = visible.filter(f => f.position === 'header')
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
116
|
+
const byName = new Map(visible.map(f => [f.name, f]))
|
|
117
|
+
const layout = definition?.layout
|
|
118
|
+
|
|
119
|
+
const sidebar: FieldDefinition[] = []
|
|
120
|
+
const inSidebar = new Set<string>()
|
|
121
|
+
for (const name of layout?.sidebar ?? []) {
|
|
122
|
+
const f = byName.get(name)
|
|
123
|
+
if (f && f.position !== 'header' && !inSidebar.has(name)) {
|
|
124
|
+
sidebar.push(f)
|
|
125
|
+
inSidebar.add(name)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const f of visible) {
|
|
129
|
+
if (f.position === 'header' || inSidebar.has(f.name)) continue
|
|
130
|
+
if (f.position === 'sidebar' || f.role) {
|
|
131
|
+
sidebar.push(f)
|
|
132
|
+
inSidebar.add(f.name)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
sidebar.sort((a, b) => roleRank(a) - roleRank(b))
|
|
136
|
+
|
|
137
|
+
const main = visible.filter(f => f.position !== 'header' && !inSidebar.has(f.name))
|
|
138
|
+
|
|
139
|
+
let sections: RenderSection[]
|
|
140
|
+
if (layout?.sections && layout.sections.length > 0) {
|
|
141
|
+
const used = new Set<string>()
|
|
142
|
+
sections = layout.sections
|
|
143
|
+
.map(s => {
|
|
144
|
+
const secFields = s.fields.map(n => byName.get(n)).filter((f): f is FieldDefinition => f !== undefined && main.includes(f))
|
|
145
|
+
for (const f of secFields) used.add(f.name)
|
|
146
|
+
return { title: s.title, fields: secFields, collapsed: s.collapsed }
|
|
147
|
+
})
|
|
148
|
+
.filter(s => s.fields.length > 0)
|
|
149
|
+
const leftover = main.filter(f => !used.has(f.name))
|
|
150
|
+
if (leftover.length > 0) sections.push({ title: 'Other', fields: leftover })
|
|
151
|
+
} else {
|
|
152
|
+
sections = deriveDefaultSections(main)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const display = layout?.display ?? (sections.length > AUTO_TAB_THRESHOLD ? 'tabs' : 'sections')
|
|
156
|
+
return { header, sidebar, sections, display }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Field + section rendering
|
|
161
|
+
// ============================================================================
|
|
53
162
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
163
|
+
/** A single field row: label (+ type + help) and its editor widget. */
|
|
164
|
+
function FieldRow({ field, draft, onField, ctx }: {
|
|
165
|
+
field: FieldDefinition
|
|
166
|
+
draft: EntryDraft
|
|
167
|
+
onField: (name: string, value: unknown) => void
|
|
168
|
+
ctx: EditorContext
|
|
169
|
+
}) {
|
|
170
|
+
return (
|
|
171
|
+
<div className={`nua-cadmin-field${field.width === 'half' ? ' nua-cadmin-field-half' : ''}${field.role ? ` nua-cadmin-field-${field.role}` : ''}`}>
|
|
172
|
+
<div className="nua-cadmin-field-label">
|
|
173
|
+
<span>{fieldLabel(field)}</span>
|
|
174
|
+
<span className="nua-cadmin-field-type">{field.type}{field.required ? ' · required' : ''}</span>
|
|
175
|
+
</div>
|
|
176
|
+
{field.help ? <div className="nua-cadmin-field-help">{field.help}</div> : null}
|
|
177
|
+
<FieldEditor
|
|
178
|
+
field={field}
|
|
179
|
+
value={draft.frontmatter[field.name]}
|
|
180
|
+
onChange={value =>
|
|
181
|
+
onField(field.name, value)}
|
|
182
|
+
ctx={ctx}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
57
186
|
}
|
|
58
187
|
|
|
59
|
-
/**
|
|
60
|
-
function
|
|
188
|
+
/** Width-aware grid of field rows (`width: 'half'` lets two share a row). */
|
|
189
|
+
function FieldGrid({ fields, draft, onField, ctx }: {
|
|
61
190
|
fields: FieldDefinition[]
|
|
62
191
|
draft: EntryDraft
|
|
63
192
|
onField: (name: string, value: unknown) => void
|
|
64
193
|
ctx: EditorContext
|
|
65
194
|
}) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
195
|
+
return (
|
|
196
|
+
<div className="nua-cadmin-field-grid">
|
|
197
|
+
{fields.map(field => <FieldRow key={field.name} field={field} draft={draft} onField={onField} ctx={ctx} />)}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Render the main column's sections, either stacked (collapsible) or as tabs. */
|
|
203
|
+
function SectionsView({ sections, display, draft, onField, ctx }: {
|
|
204
|
+
sections: RenderSection[]
|
|
205
|
+
display: 'sections' | 'tabs'
|
|
206
|
+
draft: EntryDraft
|
|
207
|
+
onField: (name: string, value: unknown) => void
|
|
208
|
+
ctx: EditorContext
|
|
209
|
+
}) {
|
|
210
|
+
const [activeTab, setActiveTab] = useState(0)
|
|
211
|
+
|
|
212
|
+
if (display === 'tabs') {
|
|
213
|
+
const active = sections[Math.min(activeTab, sections.length - 1)]
|
|
214
|
+
return (
|
|
215
|
+
<div className="nua-cadmin-tabs">
|
|
216
|
+
<div className="nua-cadmin-tabbar" role="tablist">
|
|
217
|
+
{sections.map((s, i) => (
|
|
218
|
+
<button
|
|
219
|
+
key={s.title ?? `section-${i}`}
|
|
220
|
+
type="button"
|
|
221
|
+
role="tab"
|
|
222
|
+
aria-selected={i === activeTab}
|
|
223
|
+
className={`nua-cadmin-tab${i === activeTab ? ' is-active' : ''}`}
|
|
224
|
+
onClick={() => setActiveTab(i)}
|
|
225
|
+
>
|
|
226
|
+
{s.title ?? 'General'}
|
|
227
|
+
</button>
|
|
228
|
+
))}
|
|
78
229
|
</div>
|
|
79
|
-
<
|
|
80
|
-
</div
|
|
230
|
+
{active ? <FieldGrid fields={active.fields} draft={draft} onField={onField} ctx={ctx} /> : null}
|
|
231
|
+
</div>
|
|
81
232
|
)
|
|
82
233
|
}
|
|
83
|
-
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<>
|
|
237
|
+
{sections.map((s, i) => {
|
|
238
|
+
// Untitled section → render fields directly (no collapsible chrome).
|
|
239
|
+
if (s.title === undefined) {
|
|
240
|
+
return <FieldGrid key={`section-${i}`} fields={s.fields} draft={draft} onField={onField} ctx={ctx} />
|
|
241
|
+
}
|
|
242
|
+
return (
|
|
243
|
+
<details key={s.title} className="nua-cadmin-section" open={!s.collapsed}>
|
|
244
|
+
<summary className="nua-cadmin-section-summary">{s.title}</summary>
|
|
245
|
+
<div className="nua-cadmin-section-body">
|
|
246
|
+
<FieldGrid fields={s.fields} draft={draft} onField={onField} ctx={ctx} />
|
|
247
|
+
</div>
|
|
248
|
+
</details>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
</>
|
|
252
|
+
)
|
|
84
253
|
}
|
|
85
254
|
|
|
86
255
|
// ============================================================================
|
|
@@ -141,9 +310,13 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
141
310
|
onRenamed: (newSlug: string) => void
|
|
142
311
|
}) {
|
|
143
312
|
const fields = useMemo(() => definition?.fields ?? [], [definition])
|
|
313
|
+
const isMdx = definition?.fileExtension === 'mdx'
|
|
144
314
|
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
|
145
315
|
const [loadError, setLoadError] = useState<Error | null>(null)
|
|
146
316
|
const [status, setStatus] = useState<SaveStatus>({ kind: 'idle' })
|
|
317
|
+
// Component definitions for the MDX block picker/labels. Only needed for mdx
|
|
318
|
+
// bodies; degrades to an empty list against an older sidecar (no /components).
|
|
319
|
+
const [components, setComponents] = useState<ComponentDefinition[]>([])
|
|
147
320
|
|
|
148
321
|
// `baseHash` is the optimistic-concurrency token. It starts undefined (GET
|
|
149
322
|
// exposes no hash) and is adopted from each successful `MutationResult.sourceHash`.
|
|
@@ -153,6 +326,8 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
153
326
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
154
327
|
|
|
155
328
|
const ctx: EditorContext = useMemo(() => ({ client, collection, slug }), [client, collection, slug])
|
|
329
|
+
// Upload context for the MDX editor's media library (files entry-relative uploads).
|
|
330
|
+
const mediaContext = useMemo(() => ({ collection, entry: slug }), [collection, slug])
|
|
156
331
|
|
|
157
332
|
// Load the entry → build the native draft.
|
|
158
333
|
useEffect(() => {
|
|
@@ -175,6 +350,23 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
175
350
|
}
|
|
176
351
|
}, [client, collection, slug, fields])
|
|
177
352
|
|
|
353
|
+
// Load component definitions for the MDX editor (once per client; mdx only).
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (!isMdx) return
|
|
356
|
+
let active = true
|
|
357
|
+
client.getComponents().then(
|
|
358
|
+
defs => {
|
|
359
|
+
if (active) setComponents(defs)
|
|
360
|
+
},
|
|
361
|
+
() => {
|
|
362
|
+
if (active) setComponents([])
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
return () => {
|
|
366
|
+
active = false
|
|
367
|
+
}
|
|
368
|
+
}, [client, isMdx])
|
|
369
|
+
|
|
178
370
|
// Persist the current draft. `force` re-uses the server hash to win a conflict.
|
|
179
371
|
const persist = useCallback(
|
|
180
372
|
async (next: EntryDraft, baseHash: string | undefined) => {
|
|
@@ -295,7 +487,7 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
295
487
|
)
|
|
296
488
|
}
|
|
297
489
|
|
|
298
|
-
const { header, sidebar,
|
|
490
|
+
const { header, sidebar, sections, display } = resolveLayout(definition, fields)
|
|
299
491
|
|
|
300
492
|
return (
|
|
301
493
|
<div className="nua-cadmin-editor">
|
|
@@ -309,22 +501,24 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
309
501
|
{header.length > 0
|
|
310
502
|
? (
|
|
311
503
|
<div className="nua-cadmin-editor-header-fields">
|
|
312
|
-
<
|
|
504
|
+
<FieldGrid fields={header} draft={draft} onField={onField} ctx={ctx} />
|
|
313
505
|
</div>
|
|
314
506
|
)
|
|
315
507
|
: null}
|
|
316
508
|
|
|
317
509
|
<div className="nua-cadmin-editor-grid">
|
|
318
510
|
<div className="nua-cadmin-editor-main">
|
|
319
|
-
<
|
|
511
|
+
<SectionsView sections={sections} display={display} draft={draft} onField={onField} ctx={ctx} />
|
|
320
512
|
{definition?.type !== 'data'
|
|
321
513
|
? (
|
|
322
514
|
<div className="nua-cadmin-field">
|
|
323
515
|
<div className="nua-cadmin-field-label">
|
|
324
516
|
<span>Body</span>
|
|
325
|
-
<span className="nua-cadmin-field-type">markdown</span>
|
|
517
|
+
<span className="nua-cadmin-field-type">{isMdx ? 'mdx' : 'markdown'}</span>
|
|
326
518
|
</div>
|
|
327
|
-
|
|
519
|
+
{isMdx
|
|
520
|
+
? <MdxBodyEditor value={draft.body} onChange={onBody} components={components} media={client} mediaContext={mediaContext} />
|
|
521
|
+
: <textarea className="nua-cadmin-body-editor" value={draft.body} rows={16} onChange={e => onBody(e.target.value)} />}
|
|
328
522
|
</div>
|
|
329
523
|
)
|
|
330
524
|
: null}
|
|
@@ -332,7 +526,7 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
332
526
|
{sidebar.length > 0
|
|
333
527
|
? (
|
|
334
528
|
<div className="nua-cadmin-editor-sidebar">
|
|
335
|
-
<
|
|
529
|
+
<FieldGrid fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
|
|
336
530
|
</div>
|
|
337
531
|
)
|
|
338
532
|
: null}
|
package/src/field-editor.tsx
CHANGED
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
* widgets reach the sidecar through the injected `EditorContext`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
12
|
-
import {
|
|
13
|
-
import type {
|
|
14
|
-
import {
|
|
11
|
+
import { blankValue, type CmsClient, coerceInput, valueToArray, valueToBoolean, valueToInput, valueToObject } from '@nuasite/cms-client'
|
|
12
|
+
import { MdxBodyEditor } from '@nuasite/cms-mdx-editor'
|
|
13
|
+
import type { ComponentDefinition, FieldDefinition, FieldType } from '@nuasite/cms-types'
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
15
15
|
import { MediaPicker } from './media-picker'
|
|
16
16
|
|
|
17
|
+
/** Markdown fields use the rich editor with no component blocks (plain prose + media). */
|
|
18
|
+
const NO_COMPONENTS: ComponentDefinition[] = []
|
|
19
|
+
|
|
17
20
|
/** Cross-cutting services a widget may need (media uploads, reference lookups). */
|
|
18
21
|
export interface EditorContext {
|
|
19
22
|
client: CmsClient
|
|
@@ -63,15 +66,58 @@ function TextWidget({ field, value, onChange }: { field: FieldDefinition; value:
|
|
|
63
66
|
)
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
/** Textarea that grows with its content (no inner scrollbar), with a `rows` floor. */
|
|
70
|
+
function AutosizeTextarea(
|
|
71
|
+
{ value, rows, placeholder, maxLength, onChange }: {
|
|
72
|
+
value: string
|
|
73
|
+
rows: number
|
|
74
|
+
placeholder?: string
|
|
75
|
+
maxLength?: number
|
|
76
|
+
onChange: (v: string) => void
|
|
77
|
+
},
|
|
78
|
+
) {
|
|
79
|
+
const ref = useRef<HTMLTextAreaElement>(null)
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const el = ref.current
|
|
82
|
+
if (!el) return
|
|
83
|
+
el.style.height = 'auto'
|
|
84
|
+
el.style.height = `${el.scrollHeight}px`
|
|
85
|
+
}, [value])
|
|
67
86
|
return (
|
|
68
87
|
<textarea
|
|
69
|
-
|
|
88
|
+
ref={ref}
|
|
89
|
+
className="nua-cadmin-textarea nua-cadmin-textarea-autosize"
|
|
90
|
+
value={value}
|
|
91
|
+
rows={rows}
|
|
92
|
+
placeholder={placeholder}
|
|
93
|
+
maxLength={maxLength}
|
|
94
|
+
onChange={e => onChange(e.target.value)}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function TextareaWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
|
|
100
|
+
return (
|
|
101
|
+
<AutosizeTextarea
|
|
70
102
|
value={valueToInput(value)}
|
|
71
103
|
rows={field.hints?.rows ?? 4}
|
|
72
104
|
placeholder={field.hints?.placeholder}
|
|
73
105
|
maxLength={field.hints?.maxLength}
|
|
74
|
-
onChange={
|
|
106
|
+
onChange={onChange}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Markdown field — the rich MDX/markdown WYSIWYG editor, reused for frontmatter markdown values. */
|
|
112
|
+
function MarkdownWidget({ value, onChange, ctx }: { value: unknown; onChange: (v: unknown) => void; ctx: EditorContext }) {
|
|
113
|
+
const mediaContext = useMemo(() => ({ collection: ctx.collection, entry: ctx.slug }), [ctx.collection, ctx.slug])
|
|
114
|
+
return (
|
|
115
|
+
<MdxBodyEditor
|
|
116
|
+
value={valueToInput(value)}
|
|
117
|
+
onChange={onChange}
|
|
118
|
+
components={NO_COMPONENTS}
|
|
119
|
+
media={ctx.client}
|
|
120
|
+
mediaContext={mediaContext}
|
|
75
121
|
/>
|
|
76
122
|
)
|
|
77
123
|
}
|
|
@@ -305,6 +351,8 @@ export function FieldEditor({ field, value, onChange, ctx }: FieldEditorProps) {
|
|
|
305
351
|
return <TextWidget field={field} value={value} onChange={onChange} />
|
|
306
352
|
case 'textarea':
|
|
307
353
|
return <TextareaWidget field={field} value={value} onChange={onChange} />
|
|
354
|
+
case 'markdown':
|
|
355
|
+
return <MarkdownWidget value={value} onChange={onChange} ctx={ctx} />
|
|
308
356
|
case 'number':
|
|
309
357
|
return <NumberWidget field={field} value={value} onChange={onChange} />
|
|
310
358
|
case 'year':
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { CollectionsAdminApp, type CollectionsAdminAppProps } from './app'
|
|
|
11
11
|
// the same source of truth — and so `@nuasite/cms-types` is a genuine runtime
|
|
12
12
|
// dependency (not type-only), matching the shared-contract intent.
|
|
13
13
|
export { FIELD_TYPES, isFieldType } from '@nuasite/cms-types'
|
|
14
|
+
// The headless SDK (client + form model) now lives in `@nuasite/cms-client`; the
|
|
15
|
+
// default UI surfaces it verbatim so a single import covers UI + client.
|
|
14
16
|
export {
|
|
15
17
|
type CmsApiError,
|
|
16
18
|
type CmsCapabilities,
|
|
@@ -23,10 +25,10 @@ export {
|
|
|
23
25
|
type CmsProjectModel,
|
|
24
26
|
createClient,
|
|
25
27
|
type CreateEntryInput,
|
|
28
|
+
type EntryDraft,
|
|
26
29
|
type GetEntriesOptions,
|
|
27
30
|
isMediaUnavailable,
|
|
28
31
|
type MediaContext,
|
|
29
32
|
type UpdateEntryInput,
|
|
30
33
|
type UpdateEntryResult,
|
|
31
|
-
} from '
|
|
32
|
-
export type { EntryDraft } from './form-model'
|
|
34
|
+
} from '@nuasite/cms-client'
|
package/src/media-picker.tsx
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* keeping the manual URL field fully usable — the editor is never blocked on media.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { type CmsClient, isMediaUnavailable } from '@nuasite/cms-client'
|
|
13
14
|
import { useEffect, useRef, useState } from 'react'
|
|
14
|
-
import { type CmsClient, isMediaUnavailable } from './client'
|
|
15
15
|
|
|
16
16
|
interface MediaPickerProps {
|
|
17
17
|
client: CmsClient
|
|
@@ -71,12 +71,26 @@ export function MediaPicker({ client, value, collection, entry, field, accept, o
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const looksLikeUrl = value !== '' && /^(https?:\/\/|\/)/.test(value)
|
|
75
74
|
const canUpload = state.kind === 'ready' || state.kind === 'error'
|
|
76
75
|
|
|
76
|
+
// Preview source: absolute URLs (and host-served root-relative / data URLs) load
|
|
77
|
+
// directly; an entry-relative path (e.g. an Astro `image()` value like
|
|
78
|
+
// `../../src/assets/x.webp`) is resolved + streamed by the sidecar, which needs
|
|
79
|
+
// the owning entry's slug. Only render an `<img>` when the value looks like an
|
|
80
|
+
// image, so `file` fields (PDFs, etc.) don't show a broken thumbnail.
|
|
81
|
+
const isAbsolute = value !== '' && /^(https?:\/\/|data:|\/)/.test(value)
|
|
82
|
+
const previewSrc = value === ''
|
|
83
|
+
? ''
|
|
84
|
+
: isAbsolute
|
|
85
|
+
? value
|
|
86
|
+
: entry !== undefined
|
|
87
|
+
? client.mediaFileUrl(collection, entry, value)
|
|
88
|
+
: ''
|
|
89
|
+
const showPreview = previewSrc !== '' && (value.startsWith('data:image/') || /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp)(\?|#|$)/i.test(value))
|
|
90
|
+
|
|
77
91
|
return (
|
|
78
92
|
<div className="nua-cadmin-media">
|
|
79
|
-
{
|
|
93
|
+
{showPreview ? <img className="nua-cadmin-img" src={previewSrc} alt="" /> : null}
|
|
80
94
|
<div className="nua-cadmin-media-row">
|
|
81
95
|
<input
|
|
82
96
|
type="text"
|