@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.
@@ -18,12 +18,19 @@
18
18
  * + `serverHash`; "use ours" re-PATCHes with `baseHash = serverHash` (force-over).
19
19
  */
20
20
 
21
- import type { CollectionDefinition, FieldDefinition } from '@nuasite/cms-types'
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
- 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 {
38
55
  header: FieldDefinition[]
39
56
  sidebar: FieldDefinition[]
40
- 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)
41
79
  }
42
80
 
43
81
  /**
44
- * Split visible fields into layout columns. Sidebar leads with `role`-tagged
45
- * 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.
46
86
  */
47
- function partitionFields(fields: FieldDefinition[]): PartitionedFields {
48
- 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))
49
115
  const header = visible.filter(f => f.position === 'header')
50
- // A `role`-tagged field lives in the sidebar even without an explicit position.
51
- const sidebarPositioned = visible.filter(f => f.position === 'sidebar' || (f.role !== undefined && f.position !== 'header'))
52
- 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
+ // ============================================================================
53
162
 
54
- const roleRank = (f: FieldDefinition): number => (f.role === 'publish-toggle' ? 0 : f.role === 'publish-date' ? 1 : 2)
55
- const sidebar = sidebarPositioned.slice().sort((a, b) => roleRank(a) - roleRank(b))
56
- 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
+ )
57
186
  }
58
187
 
59
- /** Render a column of fields, emitting a section header whenever `group` changes. */
60
- 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 }: {
61
190
  fields: FieldDefinition[]
62
191
  draft: EntryDraft
63
192
  onField: (name: string, value: unknown) => void
64
193
  ctx: EditorContext
65
194
  }) {
66
- let lastGroup: string | undefined
67
- const rows: React.ReactNode[] = []
68
- for (const field of fields) {
69
- if (field.group && field.group !== lastGroup) {
70
- rows.push(<h4 key={`group-${field.group}`} className="nua-cadmin-group-title">{field.group}</h4>)
71
- lastGroup = field.group
72
- }
73
- rows.push(
74
- <div key={field.name} className={`nua-cadmin-field${field.role ? ` nua-cadmin-field-${field.role}` : ''}`}>
75
- <div className="nua-cadmin-field-label">
76
- <span>{field.name}</span>
77
- <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
+ ))}
78
229
  </div>
79
- <FieldEditor field={field} value={draft.frontmatter[field.name]} onChange={value => onField(field.name, value)} ctx={ctx} />
80
- </div>,
230
+ {active ? <FieldGrid fields={active.fields} draft={draft} onField={onField} ctx={ctx} /> : null}
231
+ </div>
81
232
  )
82
233
  }
83
- 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
+ )
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, main } = partitionFields(fields)
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
- <FieldColumn fields={header} draft={draft} onField={onField} ctx={ctx} />
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
- <FieldColumn fields={main} draft={draft} onField={onField} ctx={ctx} />
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
- <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)} />}
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
- <FieldColumn fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
529
+ <FieldGrid fields={sidebar} draft={draft} onField={onField} ctx={ctx} />
336
530
  </div>
337
531
  )
338
532
  : null}
@@ -8,12 +8,15 @@
8
8
  * widgets reach the sidecar through the injected `EditorContext`.
9
9
  */
10
10
 
11
- import type { FieldDefinition, FieldType } from '@nuasite/cms-types'
12
- import { useCallback, useEffect, useState } from 'react'
13
- import type { CmsClient } from './client'
14
- import { blankValue, coerceInput, valueToArray, valueToBoolean, valueToInput, valueToObject } from './form-model'
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
- 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])
67
86
  return (
68
87
  <textarea
69
- 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
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={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}
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 './client'
32
- export type { EntryDraft } from './form-model'
34
+ } from '@nuasite/cms-client'
@@ -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
- {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"