@nuasite/collections-admin 0.43.0-beta.4 → 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/media-picker.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/app.tsx +181 -109
- package/src/entry-editor.tsx +224 -36
- package/src/field-editor.tsx +54 -5
- package/src/media-picker.tsx +16 -2
- package/src/styles.css +220 -1
- package/src/tsconfig.json +1 -0
- package/dist/types/app.js +0 -240
- package/dist/types/client.d.ts +0 -149
- package/dist/types/client.d.ts.map +0 -1
- 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/form-model.d.ts +0 -61
- package/dist/types/form-model.d.ts.map +0 -1
- package/dist/types/index.js +0 -8
package/src/entry-editor.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
type EntryDraft,
|
|
28
28
|
setDraftField,
|
|
29
29
|
} from '@nuasite/cms-client'
|
|
30
|
-
import
|
|
30
|
+
import { MdxBodyEditor } from '@nuasite/cms-mdx-editor'
|
|
31
|
+
import type { CollectionDefinition, ComponentDefinition, FieldDefinition } from '@nuasite/cms-types'
|
|
31
32
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
32
33
|
import { type EditorContext, FieldEditor } from './field-editor'
|
|
33
34
|
|
|
@@ -40,53 +41,215 @@ type SaveStatus =
|
|
|
40
41
|
| { kind: 'conflict'; conflict: CmsConflict }
|
|
41
42
|
| { kind: 'error'; message: string }
|
|
42
43
|
|
|
43
|
-
|
|
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 {
|
|
44
55
|
header: FieldDefinition[]
|
|
45
56
|
sidebar: FieldDefinition[]
|
|
46
|
-
|
|
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)
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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.
|
|
52
86
|
*/
|
|
53
|
-
function
|
|
54
|
-
|
|
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))
|
|
55
115
|
const header = visible.filter(f => f.position === 'header')
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
+
// ============================================================================
|
|
59
162
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
)
|
|
63
186
|
}
|
|
64
187
|
|
|
65
|
-
/**
|
|
66
|
-
function
|
|
188
|
+
/** Width-aware grid of field rows (`width: 'half'` lets two share a row). */
|
|
189
|
+
function FieldGrid({ fields, draft, onField, ctx }: {
|
|
67
190
|
fields: FieldDefinition[]
|
|
68
191
|
draft: EntryDraft
|
|
69
192
|
onField: (name: string, value: unknown) => void
|
|
70
193
|
ctx: EditorContext
|
|
71
194
|
}) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
))}
|
|
84
229
|
</div>
|
|
85
|
-
<
|
|
86
|
-
</div
|
|
230
|
+
{active ? <FieldGrid fields={active.fields} draft={draft} onField={onField} ctx={ctx} /> : null}
|
|
231
|
+
</div>
|
|
87
232
|
)
|
|
88
233
|
}
|
|
89
|
-
|
|
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
|
+
)
|
|
90
253
|
}
|
|
91
254
|
|
|
92
255
|
// ============================================================================
|
|
@@ -147,9 +310,13 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
147
310
|
onRenamed: (newSlug: string) => void
|
|
148
311
|
}) {
|
|
149
312
|
const fields = useMemo(() => definition?.fields ?? [], [definition])
|
|
313
|
+
const isMdx = definition?.fileExtension === 'mdx'
|
|
150
314
|
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
|
151
315
|
const [loadError, setLoadError] = useState<Error | null>(null)
|
|
152
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[]>([])
|
|
153
320
|
|
|
154
321
|
// `baseHash` is the optimistic-concurrency token. It starts undefined (GET
|
|
155
322
|
// exposes no hash) and is adopted from each successful `MutationResult.sourceHash`.
|
|
@@ -159,6 +326,8 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
159
326
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
160
327
|
|
|
161
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])
|
|
162
331
|
|
|
163
332
|
// Load the entry → build the native draft.
|
|
164
333
|
useEffect(() => {
|
|
@@ -181,6 +350,23 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
181
350
|
}
|
|
182
351
|
}, [client, collection, slug, fields])
|
|
183
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
|
+
|
|
184
370
|
// Persist the current draft. `force` re-uses the server hash to win a conflict.
|
|
185
371
|
const persist = useCallback(
|
|
186
372
|
async (next: EntryDraft, baseHash: string | undefined) => {
|
|
@@ -301,7 +487,7 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
301
487
|
)
|
|
302
488
|
}
|
|
303
489
|
|
|
304
|
-
const { header, sidebar,
|
|
490
|
+
const { header, sidebar, sections, display } = resolveLayout(definition, fields)
|
|
305
491
|
|
|
306
492
|
return (
|
|
307
493
|
<div className="nua-cadmin-editor">
|
|
@@ -315,22 +501,24 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
315
501
|
{header.length > 0
|
|
316
502
|
? (
|
|
317
503
|
<div className="nua-cadmin-editor-header-fields">
|
|
318
|
-
<
|
|
504
|
+
<FieldGrid fields={header} draft={draft} onField={onField} ctx={ctx} />
|
|
319
505
|
</div>
|
|
320
506
|
)
|
|
321
507
|
: null}
|
|
322
508
|
|
|
323
509
|
<div className="nua-cadmin-editor-grid">
|
|
324
510
|
<div className="nua-cadmin-editor-main">
|
|
325
|
-
<
|
|
511
|
+
<SectionsView sections={sections} display={display} draft={draft} onField={onField} ctx={ctx} />
|
|
326
512
|
{definition?.type !== 'data'
|
|
327
513
|
? (
|
|
328
514
|
<div className="nua-cadmin-field">
|
|
329
515
|
<div className="nua-cadmin-field-label">
|
|
330
516
|
<span>Body</span>
|
|
331
|
-
<span className="nua-cadmin-field-type">markdown</span>
|
|
517
|
+
<span className="nua-cadmin-field-type">{isMdx ? 'mdx' : 'markdown'}</span>
|
|
332
518
|
</div>
|
|
333
|
-
|
|
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)} />}
|
|
334
522
|
</div>
|
|
335
523
|
)
|
|
336
524
|
: null}
|
|
@@ -338,7 +526,7 @@ export function EntryEditor({ client, definition, collection, slug, onDeleted, o
|
|
|
338
526
|
{sidebar.length > 0
|
|
339
527
|
? (
|
|
340
528
|
<div className="nua-cadmin-editor-sidebar">
|
|
341
|
-
<
|
|
529
|
+
<FieldGrid fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
|
|
342
530
|
</div>
|
|
343
531
|
)
|
|
344
532
|
: null}
|
package/src/field-editor.tsx
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { blankValue, type CmsClient, coerceInput, valueToArray, valueToBoolean, valueToInput, valueToObject } from '@nuasite/cms-client'
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
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'
|
|
14
15
|
import { MediaPicker } from './media-picker'
|
|
15
16
|
|
|
17
|
+
/** Markdown fields use the rich editor with no component blocks (plain prose + media). */
|
|
18
|
+
const NO_COMPONENTS: ComponentDefinition[] = []
|
|
19
|
+
|
|
16
20
|
/** Cross-cutting services a widget may need (media uploads, reference lookups). */
|
|
17
21
|
export interface EditorContext {
|
|
18
22
|
client: CmsClient
|
|
@@ -62,15 +66,58 @@ function TextWidget({ field, value, onChange }: { field: FieldDefinition; value:
|
|
|
62
66
|
)
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
|
|
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])
|
|
66
86
|
return (
|
|
67
87
|
<textarea
|
|
68
|
-
|
|
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
|
|
69
102
|
value={valueToInput(value)}
|
|
70
103
|
rows={field.hints?.rows ?? 4}
|
|
71
104
|
placeholder={field.hints?.placeholder}
|
|
72
105
|
maxLength={field.hints?.maxLength}
|
|
73
|
-
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}
|
|
74
121
|
/>
|
|
75
122
|
)
|
|
76
123
|
}
|
|
@@ -304,6 +351,8 @@ export function FieldEditor({ field, value, onChange, ctx }: FieldEditorProps) {
|
|
|
304
351
|
return <TextWidget field={field} value={value} onChange={onChange} />
|
|
305
352
|
case 'textarea':
|
|
306
353
|
return <TextareaWidget field={field} value={value} onChange={onChange} />
|
|
354
|
+
case 'markdown':
|
|
355
|
+
return <MarkdownWidget value={value} onChange={onChange} ctx={ctx} />
|
|
307
356
|
case 'number':
|
|
308
357
|
return <NumberWidget field={field} value={value} onChange={onChange} />
|
|
309
358
|
case 'year':
|
package/src/media-picker.tsx
CHANGED
|
@@ -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"
|