@prenta/admin 1.2.0 → 1.3.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/__tests__/lib/page-audit-input.test.js +81 -0
  3. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  4. package/dist/__tests__/lib/version-history.test.js +18 -0
  5. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  6. package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
  7. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  8. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  9. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  10. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  11. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  12. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  15. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  16. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  17. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  18. package/dist/components/seo/SeoEditorPane.js +5 -2
  19. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  20. package/dist/hooks/usePageAudit.d.ts +4 -1
  21. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  22. package/dist/hooks/usePageAudit.js +3 -3
  23. package/dist/hooks/usePageAudit.js.map +1 -1
  24. package/dist/lib/page-audit-input.d.ts +26 -1
  25. package/dist/lib/page-audit-input.d.ts.map +1 -1
  26. package/dist/lib/page-audit-input.js +48 -2
  27. package/dist/lib/page-audit-input.js.map +1 -1
  28. package/dist/lib/version-history.d.ts +5 -0
  29. package/dist/lib/version-history.d.ts.map +1 -1
  30. package/dist/lib/version-history.js +18 -0
  31. package/dist/lib/version-history.js.map +1 -1
  32. package/dist/views/page-editor/EditorTopBar.js +1 -1
  33. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  34. package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
  35. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  37. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  38. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  39. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  40. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  41. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  42. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  43. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  45. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  47. package/dist/views/post-editor/PostSectionEditor.js +122 -34
  48. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  49. package/dist/views/post-editor/PostTab.d.ts +17 -0
  50. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  51. package/dist/views/post-editor/PostTab.js +23 -0
  52. package/dist/views/post-editor/PostTab.js.map +1 -0
  53. package/package.json +6 -6
  54. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  55. package/src/__tests__/lib/version-history.test.ts +20 -0
  56. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  57. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  58. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  59. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  60. package/src/components/seo/SeoEditorPane.tsx +16 -1
  61. package/src/hooks/usePageAudit.ts +9 -2
  62. package/src/lib/page-audit-input.ts +67 -1
  63. package/src/lib/version-history.ts +23 -0
  64. package/src/views/page-editor/EditorTopBar.tsx +2 -2
  65. package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
  66. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  67. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  68. package/src/views/post-editor/PostSectionEditor.tsx +204 -59
  69. package/src/views/post-editor/PostTab.tsx +46 -0
@@ -33,6 +33,20 @@ export interface AuditMetaSource {
33
33
  hasOgImage: boolean
34
34
  }
35
35
 
36
+ /**
37
+ * Post identity the public template renders outside the section list.
38
+ *
39
+ * The audit engine only reads `sections`. Posts put the H1, opening prose, and
40
+ * featured image on the document itself, so both audit callers (saved record
41
+ * and live SEO pane) fold these into synthetic sections here — one mapping,
42
+ * so the header chip and the rings cannot disagree.
43
+ */
44
+ export interface PostAuditExtras {
45
+ title?: string
46
+ bodyHtml?: string
47
+ featuredImage?: string
48
+ }
49
+
36
50
  export interface BuildAuditInputArgs {
37
51
  sections: PageSection[]
38
52
  meta: AuditMetaSource
@@ -41,6 +55,57 @@ export interface BuildAuditInputArgs {
41
55
  record: Pick<SeoContentRecord, 'title' | 'siteName' | 'titleTemplate' | 'url'> | null
42
56
  /** Fallback title when there is no saved record yet. */
43
57
  fallbackTitle?: string
58
+ /** Post-only slots prepended so the same engine sees title / body / image. */
59
+ post?: PostAuditExtras
60
+ }
61
+
62
+ const POST_TITLE_SECTION_ID = '__post-title'
63
+ const POST_IMAGE_SECTION_ID = '__post-featured-image'
64
+
65
+ function stripHtml(html: string): string {
66
+ return html
67
+ .replace(/<[^>]*>/g, ' ')
68
+ .replace(/\s+/g, ' ')
69
+ .trim()
70
+ }
71
+
72
+ /**
73
+ * Prepend the post's own title, body, and featured image as sections the
74
+ * audit already knows how to read (H1, opening prose, image slot).
75
+ *
76
+ * A `text` section carries title + body so we do not invent a phantom hero
77
+ * image slot. The featured image is a separate `feature` section, and only
78
+ * when one is set — otherwise `imageSlots` would count an empty media field
79
+ * and fail alt-text for an image that is not on the page.
80
+ */
81
+ export function withPostAuditSlots(sections: PageSection[], post?: PostAuditExtras): PageSection[] {
82
+ if (!post) return sections
83
+
84
+ const extras: PageSection[] = []
85
+ const title = post.title?.trim() ?? ''
86
+ const body = post.bodyHtml ? stripHtml(post.bodyHtml) : ''
87
+ if (title || body) {
88
+ extras.push({
89
+ id: POST_TITLE_SECTION_ID,
90
+ sectionType: 'text',
91
+ name: 'Post',
92
+ visible: true,
93
+ content: { heading: title, body },
94
+ settings: {},
95
+ })
96
+ }
97
+ const image = post.featuredImage?.trim() ?? ''
98
+ if (image) {
99
+ extras.push({
100
+ id: POST_IMAGE_SECTION_ID,
101
+ sectionType: 'feature',
102
+ name: 'Featured image',
103
+ visible: true,
104
+ content: { imageUrl: image, imageAlt: '' },
105
+ settings: {},
106
+ })
107
+ }
108
+ return extras.length ? [...extras, ...sections] : sections
44
109
  }
45
110
 
46
111
  export function buildPageAuditInput({
@@ -49,6 +114,7 @@ export function buildPageAuditInput({
49
114
  slug,
50
115
  record,
51
116
  fallbackTitle,
117
+ post,
52
118
  }: BuildAuditInputArgs): PageAuditInput {
53
119
  // Measure the title a searcher actually sees — the page meta title with the
54
120
  // site's template applied — not the raw field. Checking the raw field would
@@ -65,7 +131,7 @@ export function buildPageAuditInput({
65
131
  : meta.metaTitle.trim() || fallbackTitle || ''
66
132
 
67
133
  return {
68
- sections,
134
+ sections: withPostAuditSlots(sections, post),
69
135
  seo: {
70
136
  title: renderedTitle,
71
137
  description: meta.metaDescription,
@@ -35,6 +35,11 @@ export interface PageVersion {
35
35
  author: VersionAuthor
36
36
  /** Full page content as of this version — what the canvas renders. */
37
37
  sections: PageSection[]
38
+ /** Post identity fields, when the snapshot is a post. */
39
+ title?: string
40
+ excerpt?: string
41
+ featuredImage?: string
42
+ body?: string
38
43
  }
39
44
 
40
45
  /** Versions sharing a calendar day, newest day first. */
@@ -87,6 +92,20 @@ function toAuthor(row: VersionApiRow): VersionAuthor {
87
92
  }
88
93
  }
89
94
 
95
+ function optionalString(value: unknown): string | undefined {
96
+ return typeof value === 'string' ? value : undefined
97
+ }
98
+
99
+ function optionalFeaturedImage(value: unknown): string | undefined {
100
+ if (typeof value === 'string') return value
101
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
102
+ const obj = value as Record<string, unknown>
103
+ const url = obj.url ?? obj.publicUrl ?? obj.src
104
+ if (typeof url === 'string') return url
105
+ }
106
+ return undefined
107
+ }
108
+
90
109
  export function toPageVersion(row: VersionApiRow): PageVersion {
91
110
  const data = row.data ?? {}
92
111
  return {
@@ -98,6 +117,10 @@ export function toPageVersion(row: VersionApiRow): PageVersion {
98
117
  // snapshot more strictly than the current page would make an unmanaged
99
118
  // section look like it was deleted in every version that contains one.
100
119
  sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
120
+ title: optionalString(data.title),
121
+ excerpt: optionalString(data.excerpt),
122
+ featuredImage: optionalFeaturedImage(data.featuredImage),
123
+ body: optionalString(data.body),
101
124
  }
102
125
  }
103
126
 
@@ -231,8 +231,8 @@ export function EditorTopBar({
231
231
  <button
232
232
  type="button"
233
233
  onClick={onOpenSettings}
234
- aria-label="Page settings"
235
- title="Page settings"
234
+ aria-label={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
235
+ title={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
236
236
  className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2"
237
237
  >
238
238
  <Settings2 className="h-4 w-4" aria-hidden />
@@ -24,6 +24,19 @@ interface PostEditorCanvasProps {
24
24
  onAddSection?: () => void
25
25
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
26
26
  structuralTypes?: string[]
27
+ /**
28
+ * Read-only render of a past version. Suppresses selection and the editing
29
+ * affordances, and turns on the changed-section outlines.
30
+ */
31
+ reviewing?: boolean
32
+ /** Sections that differ from the version before the one being reviewed. */
33
+ changedSectionIds?: ReadonlySet<string>
34
+ /**
35
+ * Rendered in place of the editing tip / structure notice while reviewing.
36
+ * Passed in rather than built here because the review bar's actions belong
37
+ * to the editor's history state, which the canvas knows nothing about.
38
+ */
39
+ reviewBar?: React.ReactNode
27
40
  }
28
41
 
29
42
  /**
@@ -51,6 +64,9 @@ export function PostEditorCanvas({
51
64
  onCanvasElement,
52
65
  onAddSection,
53
66
  structuralTypes = [],
67
+ reviewing = false,
68
+ changedSectionIds,
69
+ reviewBar,
54
70
  }: PostEditorCanvasProps) {
55
71
  const registerRef = useSectionRefs(selectedId)
56
72
 
@@ -62,15 +78,19 @@ export function PostEditorCanvas({
62
78
  onScaleChange={onScaleChange}
63
79
  onCanvasElement={onCanvasElement}
64
80
  chrome={
65
- <>
66
- {sections.length > 0 && <CanvasEditTip />}
67
- <StructureNotice structuralTypes={structuralTypes} />
68
- </>
81
+ reviewing ? (
82
+ reviewBar
83
+ ) : (
84
+ <>
85
+ {sections.length > 0 && <CanvasEditTip />}
86
+ <StructureNotice structuralTypes={structuralTypes} />
87
+ </>
88
+ )
69
89
  }
70
90
  >
71
91
  <PostHeader config={header} context={context} />
72
92
  {sections.length === 0 ? (
73
- <EmptyBody onAddSection={onAddSection} />
93
+ <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
74
94
  ) : (
75
95
  sections.map((section) => (
76
96
  <SectionRenderer
@@ -78,6 +98,8 @@ export function PostEditorCanvas({
78
98
  section={section}
79
99
  context={context}
80
100
  editable
101
+ reviewing={reviewing}
102
+ changed={changedSectionIds?.has(section.id) ?? false}
81
103
  selected={section.id === selectedId}
82
104
  onSelect={onSelect}
83
105
  registerRef={registerRef}
@@ -0,0 +1,322 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Loader2, Sparkles } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { normalizeSlug, validateSlug } from '@prenta/core/client'
7
+ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
8
+ import { TagInput } from '../../components/TagInput.js'
9
+ import { coauthorText } from '../../lib/coauthor-client.js'
10
+
11
+ /**
12
+ * The slice of a collection's field config the form reads to pick controls:
13
+ * a `select` category renders a dropdown (structured vocabulary), an `array`
14
+ * tags field renders the chip input with autocomplete.
15
+ */
16
+ export interface PostFieldConfig {
17
+ type?: string
18
+ options?: Array<{ label: string; value: string }>
19
+ maxRows?: number
20
+ }
21
+
22
+ export interface PostFieldsFormValues {
23
+ title: string
24
+ slug: string
25
+ excerpt: string
26
+ featuredImage: string
27
+ category: string
28
+ tags: string[]
29
+ }
30
+
31
+ export interface PostFieldsFormProps {
32
+ values: PostFieldsFormValues
33
+ canEdit: boolean
34
+ /** Field definitions from the post type's collection config. */
35
+ fields?: Record<string, PostFieldConfig | undefined>
36
+ postType: string
37
+ /** HTML body, used as AI excerpt context. */
38
+ bodyHtml?: string
39
+ slugTouched: boolean
40
+ onSlugTouched: () => void
41
+ onChange: (patch: Partial<PostFieldsFormValues>) => void
42
+ titleRef?: React.Ref<HTMLInputElement>
43
+ /** Prefix for input ids so the modal and the rail tab can coexist. */
44
+ idPrefix?: string
45
+ }
46
+
47
+ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
48
+ /** Match TipTap body editor borders — `border-input` is transparent in theme.css. */
49
+ const INPUT =
50
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
51
+
52
+ const slugify = normalizeSlug
53
+
54
+ function stripHtml(html: string): string {
55
+ return html
56
+ .replace(/<[^>]*>/g, ' ')
57
+ .replace(/\s+/g, ' ')
58
+ .trim()
59
+ }
60
+
61
+ function buildContentContext(title: string, body: string, excerpt: string): string {
62
+ const plain = stripHtml(body)
63
+ const parts = [title.trim(), excerpt.trim(), plain].filter(Boolean)
64
+ return parts.join('\n\n')
65
+ }
66
+
67
+ function AiFieldButton({
68
+ label,
69
+ busy,
70
+ disabled,
71
+ onClick,
72
+ }: {
73
+ label: string
74
+ busy: boolean
75
+ disabled?: boolean
76
+ onClick: () => void
77
+ }) {
78
+ return (
79
+ <button
80
+ type="button"
81
+ aria-label={label}
82
+ title={label}
83
+ disabled={disabled || busy}
84
+ onClick={onClick}
85
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
86
+ >
87
+ {busy ? (
88
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
89
+ ) : (
90
+ <Sparkles className="h-3 w-3" aria-hidden />
91
+ )}
92
+ {busy ? 'Generating…' : 'AI'}
93
+ </button>
94
+ )
95
+ }
96
+
97
+ /** Title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
98
+ export function PostFieldsForm({
99
+ values,
100
+ canEdit,
101
+ fields,
102
+ postType,
103
+ bodyHtml = '',
104
+ slugTouched,
105
+ onSlugTouched,
106
+ onChange,
107
+ titleRef,
108
+ idPrefix = 'pf',
109
+ }: PostFieldsFormProps) {
110
+ const [mediaOpen, setMediaOpen] = useState(false)
111
+ const [aiBusy, setAiBusy] = useState(false)
112
+
113
+ const categoryField = fields?.category
114
+ const categoryOptions =
115
+ categoryField?.type === 'select' && Array.isArray(categoryField.options)
116
+ ? categoryField.options
117
+ : null
118
+ const tagsField = fields?.tags
119
+ const hasTags = tagsField?.type === 'array'
120
+ const slugError = validateSlug(values.slug)
121
+
122
+ function handleTitle(next: string) {
123
+ const patch: Partial<PostFieldsFormValues> = { title: next }
124
+ if (!slugTouched) patch.slug = slugify(next)
125
+ onChange(patch)
126
+ }
127
+
128
+ async function handleAiGenerateExcerpt() {
129
+ const context = buildContentContext(values.title, bodyHtml, values.excerpt)
130
+ if (!context.trim()) {
131
+ toast.error(
132
+ 'Add a title or write body content in the Article Body section before generating with AI.',
133
+ )
134
+ return
135
+ }
136
+
137
+ setAiBusy(true)
138
+ try {
139
+ const outcome = await coauthorText('compress', context, {
140
+ instructions:
141
+ 'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
142
+ targetLength: 45,
143
+ })
144
+ if (!outcome.ok) {
145
+ toast.error(outcome.error)
146
+ return
147
+ }
148
+ onChange({ excerpt: outcome.result.text.trim() })
149
+ toast.success('Excerpt generated — review before saving.')
150
+ } catch {
151
+ toast.error('AI request failed.')
152
+ } finally {
153
+ setAiBusy(false)
154
+ }
155
+ }
156
+
157
+ const titleId = `${idPrefix}-title`
158
+ const slugId = `${idPrefix}-slug`
159
+ const categoryId = `${idPrefix}-category`
160
+ const tagsId = `${idPrefix}-tags`
161
+ const excerptId = `${idPrefix}-excerpt`
162
+ const imageId = `${idPrefix}-image`
163
+
164
+ return (
165
+ <>
166
+ <div className="space-y-3">
167
+ <div>
168
+ <label className={LABEL} htmlFor={titleId}>
169
+ Title <span className="text-destructive">*</span>
170
+ </label>
171
+ <input
172
+ id={titleId}
173
+ ref={titleRef}
174
+ className={INPUT}
175
+ value={values.title}
176
+ disabled={!canEdit}
177
+ placeholder="e.g. Announcing our new product"
178
+ onChange={(e) => handleTitle(e.target.value)}
179
+ />
180
+ </div>
181
+
182
+ <div className="grid grid-cols-2 gap-3">
183
+ <div>
184
+ <label className={LABEL} htmlFor={slugId}>
185
+ Slug <span className="text-destructive">*</span>
186
+ </label>
187
+ <input
188
+ id={slugId}
189
+ className={INPUT}
190
+ value={values.slug}
191
+ disabled={!canEdit}
192
+ placeholder={slugTouched ? undefined : 'Generated from title…'}
193
+ aria-invalid={Boolean(slugError)}
194
+ aria-describedby={slugError ? `${slugId}-error` : undefined}
195
+ onChange={(e) => {
196
+ onSlugTouched()
197
+ onChange({ slug: slugify(e.target.value) })
198
+ }}
199
+ />
200
+ {slugError && (
201
+ <p id={`${slugId}-error`} className="text-destructive mt-1 text-xs">
202
+ {slugError}
203
+ </p>
204
+ )}
205
+ </div>
206
+
207
+ <div>
208
+ <label className={LABEL} htmlFor={categoryId}>
209
+ Category
210
+ </label>
211
+ {categoryOptions ? (
212
+ <select
213
+ id={categoryId}
214
+ className={INPUT}
215
+ value={values.category}
216
+ disabled={!canEdit}
217
+ onChange={(e) => onChange({ category: e.target.value })}
218
+ >
219
+ <option value="">No category</option>
220
+ {categoryOptions.map((o) => (
221
+ <option key={o.value} value={o.value}>
222
+ {o.label}
223
+ </option>
224
+ ))}
225
+ {values.category && !categoryOptions.some((o) => o.value === values.category) && (
226
+ <option value={values.category}>{values.category}</option>
227
+ )}
228
+ </select>
229
+ ) : (
230
+ <input
231
+ id={categoryId}
232
+ className={INPUT}
233
+ value={values.category}
234
+ disabled={!canEdit}
235
+ placeholder="e.g. Product"
236
+ onChange={(e) => onChange({ category: e.target.value })}
237
+ />
238
+ )}
239
+ </div>
240
+ </div>
241
+
242
+ {hasTags && (
243
+ <div>
244
+ <label className={LABEL} htmlFor={tagsId}>
245
+ Tags
246
+ </label>
247
+ <TagInput
248
+ id={tagsId}
249
+ value={values.tags}
250
+ onChange={(tags) => onChange({ tags })}
251
+ collection={postType}
252
+ field="tags"
253
+ disabled={!canEdit}
254
+ maxTags={tagsField?.maxRows}
255
+ />
256
+ </div>
257
+ )}
258
+
259
+ <div>
260
+ <div className="mb-1 flex items-center justify-between gap-2">
261
+ <label className={`${LABEL} mb-0`} htmlFor={excerptId}>
262
+ Excerpt
263
+ </label>
264
+ <AiFieldButton
265
+ label="Generate excerpt from content"
266
+ busy={aiBusy}
267
+ disabled={!canEdit}
268
+ onClick={() => void handleAiGenerateExcerpt()}
269
+ />
270
+ </div>
271
+ <textarea
272
+ id={excerptId}
273
+ className={`${INPUT} min-h-[72px] resize-y`}
274
+ value={values.excerpt}
275
+ disabled={!canEdit}
276
+ placeholder="A short summary shown in listings and the header."
277
+ onChange={(e) => onChange({ excerpt: e.target.value })}
278
+ />
279
+ </div>
280
+
281
+ <div>
282
+ <label className={LABEL} htmlFor={imageId}>
283
+ Featured image
284
+ </label>
285
+ <div className="flex gap-2">
286
+ <input
287
+ id={imageId}
288
+ className={`${INPUT} min-w-0 flex-1`}
289
+ value={values.featuredImage}
290
+ disabled={!canEdit}
291
+ placeholder="https://… or pick from the media library"
292
+ onChange={(e) => onChange({ featuredImage: e.target.value })}
293
+ />
294
+ <button
295
+ type="button"
296
+ disabled={!canEdit}
297
+ onClick={() => setMediaOpen(true)}
298
+ className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
299
+ >
300
+ Browse
301
+ </button>
302
+ </div>
303
+ {values.featuredImage && (
304
+ <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
305
+ <img src={values.featuredImage} alt="" className="h-28 w-full object-cover" />
306
+ </div>
307
+ )}
308
+ </div>
309
+ </div>
310
+
311
+ <MediaPickerModal
312
+ open={mediaOpen}
313
+ onClose={() => setMediaOpen(false)}
314
+ onSelect={(url) => {
315
+ onChange({ featuredImage: url })
316
+ setMediaOpen(false)
317
+ }}
318
+ accept="image/*"
319
+ />
320
+ </>
321
+ )
322
+ }