@nuasite/collections-admin 0.43.0-beta.4 → 0.43.0

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.
@@ -27,7 +27,8 @@ import {
27
27
  type EntryDraft,
28
28
  setDraftField,
29
29
  } from '@nuasite/cms-client'
30
- import type { CollectionDefinition, FieldDefinition } from '@nuasite/cms-types'
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
- interface PartitionedFields {
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
- main: FieldDefinition[]
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
- * Split visible fields into layout columns. Sidebar leads with `role`-tagged
51
- * publish fields (toggle then date), then other sidebar-positioned fields.
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 partitionFields(fields: FieldDefinition[]): PartitionedFields {
54
- const visible = fields.filter(f => !f.hidden)
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
- // A `role`-tagged field lives in the sidebar even without an explicit position.
57
- const sidebarPositioned = visible.filter(f => f.position === 'sidebar' || (f.role !== undefined && f.position !== 'header'))
58
- const main = visible.filter(f => f.position === undefined && f.role === undefined)
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
- const roleRank = (f: FieldDefinition): number => (f.role === 'publish-toggle' ? 0 : f.role === 'publish-date' ? 1 : 2)
61
- const sidebar = sidebarPositioned.slice().sort((a, b) => roleRank(a) - roleRank(b))
62
- return { header, sidebar, main }
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
- /** Render a column of fields, emitting a section header whenever `group` changes. */
66
- function FieldColumn({ fields, draft, onField, ctx }: {
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
- let lastGroup: string | undefined
73
- const rows: React.ReactNode[] = []
74
- for (const field of fields) {
75
- if (field.group && field.group !== lastGroup) {
76
- rows.push(<h4 key={`group-${field.group}`} className="nua-cadmin-group-title">{field.group}</h4>)
77
- lastGroup = field.group
78
- }
79
- rows.push(
80
- <div key={field.name} className={`nua-cadmin-field${field.role ? ` nua-cadmin-field-${field.role}` : ''}`}>
81
- <div className="nua-cadmin-field-label">
82
- <span>{field.name}</span>
83
- <span className="nua-cadmin-field-type">{field.type}{field.required ? ' · required' : ''}</span>
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
- <FieldEditor field={field} value={draft.frontmatter[field.name]} onChange={value => onField(field.name, value)} ctx={ctx} />
86
- </div>,
230
+ {active ? <FieldGrid fields={active.fields} draft={draft} onField={onField} ctx={ctx} /> : null}
231
+ </div>
87
232
  )
88
233
  }
89
- return <>{rows}</>
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, main } = partitionFields(fields)
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
- <FieldColumn fields={header} draft={draft} onField={onField} ctx={ctx} />
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
- <FieldColumn fields={main} draft={draft} onField={onField} ctx={ctx} />
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
- <textarea className="nua-cadmin-body-editor" value={draft.body} rows={16} onChange={e => onBody(e.target.value)} />
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
- <FieldColumn fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
529
+ <FieldGrid fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
342
530
  </div>
343
531
  )
344
532
  : null}
@@ -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 type { FieldDefinition, FieldType } from '@nuasite/cms-types'
13
- import { useCallback, useEffect, useState } from 'react'
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
- function TextareaWidget({ field, value, onChange }: { field: FieldDefinition; value: unknown; onChange: (v: unknown) => void }) {
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
- className="nua-cadmin-textarea"
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={e => onChange(e.target.value)}
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':
@@ -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
- {looksLikeUrl ? <img className="nua-cadmin-img" src={value} alt="" /> : null}
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"