@actuate-media/cms-admin 0.72.2 → 0.72.3

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 (51) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/__tests__/components/toggle.test.d.ts +2 -0
  3. package/dist/__tests__/components/toggle.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/toggle.test.js +17 -0
  5. package/dist/__tests__/components/toggle.test.js.map +1 -0
  6. package/dist/__tests__/views/post-fields-modal.render.test.js +38 -0
  7. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  10. package/dist/components/MediaPickerModal.js +1 -1
  11. package/dist/components/MediaPickerModal.js.map +1 -1
  12. package/dist/components/TagInput.js +1 -1
  13. package/dist/components/TagInput.js.map +1 -1
  14. package/dist/components/ui/Modal.d.ts +17 -1
  15. package/dist/components/ui/Modal.d.ts.map +1 -1
  16. package/dist/components/ui/Modal.js +29 -3
  17. package/dist/components/ui/Modal.js.map +1 -1
  18. package/dist/components/ui/Toggle.d.ts.map +1 -1
  19. package/dist/components/ui/Toggle.js +4 -3
  20. package/dist/components/ui/Toggle.js.map +1 -1
  21. package/dist/fields/RichTextField.d.ts +3 -1
  22. package/dist/fields/RichTextField.d.ts.map +1 -1
  23. package/dist/fields/RichTextField.js +2 -2
  24. package/dist/fields/RichTextField.js.map +1 -1
  25. package/dist/views/Dashboard.d.ts.map +1 -1
  26. package/dist/views/Dashboard.js +1 -1
  27. package/dist/views/Dashboard.js.map +1 -1
  28. package/dist/views/page-editor/SectionInspector.d.ts +4 -1
  29. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  30. package/dist/views/page-editor/SectionInspector.js +8 -2
  31. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  32. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  33. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  34. package/dist/views/post-editor/PostFieldsModal.js +152 -13
  35. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  36. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  37. package/dist/views/post-editor/PostSectionEditor.js +6 -1
  38. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/__tests__/components/toggle.test.tsx +17 -0
  41. package/src/__tests__/views/post-fields-modal.render.test.tsx +54 -0
  42. package/src/components/MediaPickerModal.tsx +6 -2
  43. package/src/components/TagInput.tsx +1 -1
  44. package/src/components/ui/Modal.tsx +46 -3
  45. package/src/components/ui/Toggle.tsx +4 -3
  46. package/src/fields/RichTextField.tsx +4 -1
  47. package/src/styles/actuate-tokens.css +7 -2
  48. package/src/views/Dashboard.tsx +2 -1
  49. package/src/views/page-editor/SectionInspector.tsx +23 -1
  50. package/src/views/post-editor/PostFieldsModal.tsx +412 -174
  51. package/src/views/post-editor/PostSectionEditor.tsx +8 -0
@@ -1,13 +1,17 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, useState } from 'react'
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+ import { Loader2, Sparkles } from 'lucide-react'
5
+ import { toast } from 'sonner'
4
6
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
7
  import { Modal } from '../../components/ui/Modal.js'
6
8
  import { Button } from '../../components/ui/Button.js'
9
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
10
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
8
11
  import { TagInput } from '../../components/TagInput.js'
9
- import { RichTextField } from '../../fields/RichTextField.js'
10
12
  import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
13
+ import { coauthorText } from '../../lib/coauthor-client.js'
14
+ import { generateSeoField } from '../../lib/seo-service.js'
11
15
  import type { EditorPost } from '../../lib/post-editor-service.js'
12
16
 
13
17
  const SEO_TITLE_MIN = 30
@@ -37,12 +41,98 @@ interface PostFieldsModalProps {
37
41
  }
38
42
 
39
43
  const LABEL = 'text-foreground mb-1 block text-xs font-medium'
44
+ /** Match TipTap body editor borders — `border-input` is transparent in theme.css. */
40
45
  const INPUT =
41
- 'border-input 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'
46
+ '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'
42
47
 
43
48
  const slugify = normalizeSlug
44
49
 
45
- /** Edits post-level fields: title, slug, excerpt, featured image, category, tags, body, SEO. */
50
+ type AiTarget = 'excerpt' | 'seoTitle' | 'seoDescription'
51
+
52
+ type FormSnapshot = {
53
+ title: string
54
+ slug: string
55
+ excerpt: string
56
+ featuredImage: string
57
+ category: string
58
+ tags: string
59
+ seoTitle: string
60
+ seoDescription: string
61
+ }
62
+
63
+ function snapshotFromPost(post: EditorPost): FormSnapshot {
64
+ return {
65
+ title: post.title,
66
+ slug: post.slug,
67
+ excerpt: post.excerpt,
68
+ featuredImage: post.featuredImage,
69
+ category: post.category,
70
+ tags: [...post.tags].sort().join('\0'),
71
+ seoTitle: post.seoTitle,
72
+ seoDescription: post.seoDescription,
73
+ }
74
+ }
75
+
76
+ function snapshotFromFields(input: {
77
+ title: string
78
+ slug: string
79
+ excerpt: string
80
+ featuredImage: string
81
+ category: string
82
+ tags: string[]
83
+ seoTitle: string
84
+ seoDescription: string
85
+ }): FormSnapshot {
86
+ return {
87
+ ...input,
88
+ tags: [...input.tags].sort().join('\0'),
89
+ }
90
+ }
91
+
92
+ function stripHtml(html: string): string {
93
+ return html
94
+ .replace(/<[^>]*>/g, ' ')
95
+ .replace(/\s+/g, ' ')
96
+ .trim()
97
+ }
98
+
99
+ function buildContentContext(title: string, body: string, excerpt: string): string {
100
+ const plain = stripHtml(body)
101
+ const parts = [title.trim(), excerpt.trim(), plain].filter(Boolean)
102
+ return parts.join('\n\n')
103
+ }
104
+
105
+ function AiFieldButton({
106
+ label,
107
+ busy,
108
+ disabled,
109
+ onClick,
110
+ }: {
111
+ label: string
112
+ busy: boolean
113
+ disabled?: boolean
114
+ onClick: () => void
115
+ }) {
116
+ return (
117
+ <button
118
+ type="button"
119
+ aria-label={label}
120
+ title={label}
121
+ disabled={disabled || busy}
122
+ onClick={onClick}
123
+ 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"
124
+ >
125
+ {busy ? (
126
+ <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
127
+ ) : (
128
+ <Sparkles className="h-3 w-3" aria-hidden />
129
+ )}
130
+ {busy ? 'Generating…' : 'AI'}
131
+ </button>
132
+ )
133
+ }
134
+
135
+ /** Edits post metadata: title, slug, excerpt, featured image, category, tags, SEO. */
46
136
  export function PostFieldsModal({
47
137
  open,
48
138
  post,
@@ -57,11 +147,13 @@ export function PostFieldsModal({
57
147
  const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
58
148
  const [category, setCategory] = useState(post.category)
59
149
  const [tags, setTags] = useState<string[]>(post.tags)
60
- const [body, setBody] = useState(post.body)
61
150
  const [seoTitle, setSeoTitle] = useState(post.seoTitle)
62
151
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
63
152
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
64
153
  const [mediaOpen, setMediaOpen] = useState(false)
154
+ const [aiBusy, setAiBusy] = useState<AiTarget | null>(null)
155
+ const [confirmDiscard, setConfirmDiscard] = useState(false)
156
+ const baselineRef = useRef<FormSnapshot | null>(null)
65
157
  // Land the user in the Title field (name-it-first flow for new posts).
66
158
  const titleRef = useRef<HTMLInputElement | null>(null)
67
159
 
@@ -74,19 +166,47 @@ export function PostFieldsModal({
74
166
  const hasTags = tagsField?.type === 'array'
75
167
 
76
168
  useEffect(() => {
77
- if (!open) return
169
+ if (!open) {
170
+ baselineRef.current = null
171
+ setConfirmDiscard(false)
172
+ return
173
+ }
78
174
  setTitle(post.title)
79
175
  setSlug(post.slug)
80
176
  setExcerpt(post.excerpt)
81
177
  setFeaturedImage(post.featuredImage)
82
178
  setCategory(post.category)
83
179
  setTags(post.tags)
84
- setBody(post.body)
85
180
  setSeoTitle(post.seoTitle)
86
181
  setSeoDescription(post.seoDescription)
87
182
  setSlugTouched(Boolean(post.slug))
183
+ setAiBusy(null)
184
+ baselineRef.current = snapshotFromPost(post)
88
185
  }, [open, post])
89
186
 
187
+ const isDirty = useMemo(() => {
188
+ if (!open || !baselineRef.current) return false
189
+ const current = snapshotFromFields({
190
+ title,
191
+ slug,
192
+ excerpt,
193
+ featuredImage,
194
+ category,
195
+ tags,
196
+ seoTitle,
197
+ seoDescription,
198
+ })
199
+ return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
200
+ }, [open, title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription])
201
+
202
+ function requestDismiss() {
203
+ if (isDirty) {
204
+ setConfirmDiscard(true)
205
+ return
206
+ }
207
+ onClose()
208
+ }
209
+
90
210
  function handleTitle(next: string) {
91
211
  setTitle(next)
92
212
  if (!slugTouched) setSlug(slugify(next))
@@ -94,201 +214,308 @@ export function PostFieldsModal({
94
214
 
95
215
  const slugError = validateSlug(slug)
96
216
 
217
+ async function handleAiGenerate(target: AiTarget) {
218
+ const context = buildContentContext(title, post.body, excerpt)
219
+ if (!context.trim()) {
220
+ toast.error(
221
+ 'Add a title or write body content in the Article Body section before generating with AI.',
222
+ )
223
+ return
224
+ }
225
+
226
+ setAiBusy(target)
227
+ try {
228
+ if (target === 'excerpt') {
229
+ const outcome = await coauthorText('compress', context, {
230
+ instructions:
231
+ 'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
232
+ targetLength: 45,
233
+ })
234
+ if (!outcome.ok) {
235
+ toast.error(outcome.error)
236
+ return
237
+ }
238
+ setExcerpt(outcome.result.text.trim())
239
+ toast.success('Excerpt generated — review before saving.')
240
+ return
241
+ }
242
+
243
+ if (post.id) {
244
+ const field = target === 'seoTitle' ? 'metaTitle' : 'metaDescription'
245
+ const result = await generateSeoField(field, {
246
+ entityType: post.postType,
247
+ entityId: post.id,
248
+ })
249
+ if (result.unavailable) {
250
+ toast.error('AI is not configured. Add an AI provider to enable suggestions.')
251
+ return
252
+ }
253
+ if (result.text) {
254
+ if (target === 'seoTitle') setSeoTitle(result.text)
255
+ else setSeoDescription(result.text)
256
+ toast.success('AI suggestion applied — review before saving.')
257
+ } else {
258
+ toast.error('No suggestion was returned.')
259
+ }
260
+ return
261
+ }
262
+
263
+ if (target === 'seoTitle') {
264
+ const outcome = await coauthorText('rewrite', context, {
265
+ style: 'seo-optimized',
266
+ instructions:
267
+ 'Write a single SEO page title under 60 characters. Return only the title — no quotes.',
268
+ })
269
+ if (!outcome.ok) {
270
+ toast.error(outcome.error)
271
+ return
272
+ }
273
+ setSeoTitle(outcome.result.text.trim().slice(0, SEO_TITLE_MAX))
274
+ } else {
275
+ const outcome = await coauthorText('compress', context, {
276
+ instructions:
277
+ 'Write a compelling SEO meta description of 120–160 characters for search results. Return only the description.',
278
+ targetLength: 155,
279
+ })
280
+ if (!outcome.ok) {
281
+ toast.error(outcome.error)
282
+ return
283
+ }
284
+ setSeoDescription(outcome.result.text.trim().slice(0, SEO_DESC_MAX))
285
+ }
286
+ toast.success('AI suggestion applied — review before saving.')
287
+ } catch {
288
+ toast.error('AI request failed.')
289
+ } finally {
290
+ setAiBusy(null)
291
+ }
292
+ }
293
+
97
294
  function handleSave() {
98
295
  if (slugError) return
99
- onSave({ title, slug, excerpt, featuredImage, category, tags, body, seoTitle, seoDescription })
296
+ onSave({ title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription })
100
297
  onClose()
101
298
  }
102
299
 
103
300
  return (
104
- <Modal
105
- open={open}
106
- onClose={onClose}
107
- title="Post details"
108
- initialFocusRef={titleRef}
109
- actions={
110
- <>
111
- <Button variant="ghost" onClick={onClose}>
112
- Cancel
113
- </Button>
114
- <Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
115
- Done
116
- </Button>
117
- </>
118
- }
119
- >
120
- <div className="space-y-3">
121
- <div>
122
- <label className={LABEL} htmlFor="pf-title">
123
- Title <span className="text-destructive">*</span>
124
- </label>
125
- <input
126
- id="pf-title"
127
- ref={titleRef}
128
- className={INPUT}
129
- value={title}
130
- disabled={!canEdit}
131
- placeholder="e.g. Announcing our new product"
132
- onChange={(e) => handleTitle(e.target.value)}
133
- />
134
- </div>
135
- <div className="grid grid-cols-2 gap-3">
301
+ <>
302
+ <Modal
303
+ open={open}
304
+ onClose={onClose}
305
+ onDismiss={requestDismiss}
306
+ title="Post details"
307
+ size="lg"
308
+ initialFocusRef={titleRef}
309
+ actions={
310
+ <>
311
+ <Button variant="ghost" onClick={requestDismiss}>
312
+ Cancel
313
+ </Button>
314
+ <Button
315
+ variant="primary"
316
+ onClick={handleSave}
317
+ disabled={!canEdit || Boolean(slugError)}
318
+ >
319
+ Done
320
+ </Button>
321
+ </>
322
+ }
323
+ >
324
+ <div className="space-y-3">
136
325
  <div>
137
- <label className={LABEL} htmlFor="pf-slug">
138
- Slug <span className="text-destructive">*</span>
326
+ <label className={LABEL} htmlFor="pf-title">
327
+ Title <span className="text-destructive">*</span>
139
328
  </label>
140
329
  <input
141
- id="pf-slug"
330
+ id="pf-title"
331
+ ref={titleRef}
142
332
  className={INPUT}
143
- value={slug}
333
+ value={title}
144
334
  disabled={!canEdit}
145
- aria-invalid={Boolean(slugError)}
146
- aria-describedby={slugError ? 'pf-slug-error' : undefined}
147
- onChange={(e) => {
148
- setSlugTouched(true)
149
- setSlug(slugify(e.target.value))
150
- }}
335
+ placeholder="e.g. Announcing our new product"
336
+ onChange={(e) => handleTitle(e.target.value)}
151
337
  />
152
- {slugError && (
153
- <p id="pf-slug-error" className="text-destructive mt-1 text-xs">
154
- {slugError}
155
- </p>
156
- )}
157
338
  </div>
158
- <div>
159
- <label className={LABEL} htmlFor="pf-category">
160
- Category
161
- </label>
162
- {categoryOptions ? (
163
- <select
164
- id="pf-category"
165
- className={INPUT}
166
- value={category}
167
- disabled={!canEdit}
168
- onChange={(e) => setCategory(e.target.value)}
169
- >
170
- <option value="">No category</option>
171
- {categoryOptions.map((o) => (
172
- <option key={o.value} value={o.value}>
173
- {o.label}
174
- </option>
175
- ))}
176
- {/* Keep a legacy free-text value selectable so opening the
177
- modal never silently discards it. */}
178
- {category && !categoryOptions.some((o) => o.value === category) && (
179
- <option value={category}>{category}</option>
180
- )}
181
- </select>
182
- ) : (
339
+
340
+ <div className="grid grid-cols-2 gap-3">
341
+ <div>
342
+ <label className={LABEL} htmlFor="pf-slug">
343
+ Slug <span className="text-destructive">*</span>
344
+ </label>
183
345
  <input
184
- id="pf-category"
346
+ id="pf-slug"
185
347
  className={INPUT}
186
- value={category}
348
+ value={slug}
187
349
  disabled={!canEdit}
188
- placeholder="e.g. Product"
189
- onChange={(e) => setCategory(e.target.value)}
350
+ placeholder={slugTouched ? undefined : 'Generated from title…'}
351
+ aria-invalid={Boolean(slugError)}
352
+ aria-describedby={slugError ? 'pf-slug-error' : undefined}
353
+ onChange={(e) => {
354
+ setSlugTouched(true)
355
+ setSlug(slugify(e.target.value))
356
+ }}
190
357
  />
191
- )}
358
+ {slugError && (
359
+ <p id="pf-slug-error" className="text-destructive mt-1 text-xs">
360
+ {slugError}
361
+ </p>
362
+ )}
363
+ </div>
364
+
365
+ <div>
366
+ <label className={LABEL} htmlFor="pf-category">
367
+ Category
368
+ </label>
369
+ {categoryOptions ? (
370
+ <select
371
+ id="pf-category"
372
+ className={INPUT}
373
+ value={category}
374
+ disabled={!canEdit}
375
+ onChange={(e) => setCategory(e.target.value)}
376
+ >
377
+ <option value="">No category</option>
378
+ {categoryOptions.map((o) => (
379
+ <option key={o.value} value={o.value}>
380
+ {o.label}
381
+ </option>
382
+ ))}
383
+ {category && !categoryOptions.some((o) => o.value === category) && (
384
+ <option value={category}>{category}</option>
385
+ )}
386
+ </select>
387
+ ) : (
388
+ <input
389
+ id="pf-category"
390
+ className={INPUT}
391
+ value={category}
392
+ disabled={!canEdit}
393
+ placeholder="e.g. Product"
394
+ onChange={(e) => setCategory(e.target.value)}
395
+ />
396
+ )}
397
+ </div>
192
398
  </div>
193
- </div>
194
- {hasTags && (
399
+
400
+ {hasTags && (
401
+ <div>
402
+ <label className={LABEL} htmlFor="pf-tags">
403
+ Tags
404
+ </label>
405
+ <TagInput
406
+ id="pf-tags"
407
+ value={tags}
408
+ onChange={setTags}
409
+ collection={post.postType}
410
+ field="tags"
411
+ disabled={!canEdit}
412
+ maxTags={tagsField?.maxRows}
413
+ />
414
+ </div>
415
+ )}
416
+
195
417
  <div>
196
- <label className={LABEL} htmlFor="pf-tags">
197
- Tags
198
- </label>
199
- <TagInput
200
- id="pf-tags"
201
- value={tags}
202
- onChange={setTags}
203
- collection={post.postType}
204
- field="tags"
418
+ <div className="mb-1 flex items-center justify-between gap-2">
419
+ <label className={`${LABEL} mb-0`} htmlFor="pf-excerpt">
420
+ Excerpt
421
+ </label>
422
+ <AiFieldButton
423
+ label="Generate excerpt from content"
424
+ busy={aiBusy === 'excerpt'}
425
+ disabled={!canEdit}
426
+ onClick={() => void handleAiGenerate('excerpt')}
427
+ />
428
+ </div>
429
+ <textarea
430
+ id="pf-excerpt"
431
+ className={`${INPUT} min-h-[72px] resize-y`}
432
+ value={excerpt}
205
433
  disabled={!canEdit}
206
- maxTags={tagsField?.maxRows}
434
+ placeholder="A short summary shown in listings and the header."
435
+ onChange={(e) => setExcerpt(e.target.value)}
207
436
  />
208
437
  </div>
209
- )}
210
- <div>
211
- <label className={LABEL} htmlFor="pf-excerpt">
212
- Excerpt
213
- </label>
214
- <textarea
215
- id="pf-excerpt"
216
- className={`${INPUT} min-h-[60px] resize-y`}
217
- value={excerpt}
218
- disabled={!canEdit}
219
- placeholder="A short summary shown in listings and the header."
220
- onChange={(e) => setExcerpt(e.target.value)}
221
- />
222
- </div>
223
- <div>
224
- <label className={LABEL} htmlFor="pf-image">
225
- Featured image
226
- </label>
227
- <div className="flex gap-2">
438
+
439
+ <div>
440
+ <label className={LABEL} htmlFor="pf-image">
441
+ Featured image
442
+ </label>
443
+ <div className="flex gap-2">
444
+ <input
445
+ id="pf-image"
446
+ className={`${INPUT} min-w-0 flex-1`}
447
+ value={featuredImage}
448
+ disabled={!canEdit}
449
+ placeholder="https://… or pick from the media library"
450
+ onChange={(e) => setFeaturedImage(e.target.value)}
451
+ />
452
+ <button
453
+ type="button"
454
+ disabled={!canEdit}
455
+ onClick={() => setMediaOpen(true)}
456
+ 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"
457
+ >
458
+ Browse
459
+ </button>
460
+ </div>
461
+ {featuredImage && (
462
+ <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
463
+ <img src={featuredImage} alt="" className="h-28 w-full object-cover" />
464
+ </div>
465
+ )}
466
+ </div>
467
+
468
+ <div>
469
+ <div className="mb-1 flex items-center justify-between gap-2">
470
+ <div className="flex min-w-0 items-center gap-2">
471
+ <label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
472
+ SEO title
473
+ </label>
474
+ <AiFieldButton
475
+ label="Generate SEO title from content"
476
+ busy={aiBusy === 'seoTitle'}
477
+ disabled={!canEdit}
478
+ onClick={() => void handleAiGenerate('seoTitle')}
479
+ />
480
+ </div>
481
+ <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
482
+ </div>
228
483
  <input
229
- id="pf-image"
230
- className={`${INPUT} flex-1`}
231
- value={featuredImage}
484
+ id="pf-seo-title"
485
+ className={INPUT}
486
+ value={seoTitle}
232
487
  disabled={!canEdit}
233
- placeholder="https://… or pick from the media library"
234
- onChange={(e) => setFeaturedImage(e.target.value)}
488
+ placeholder={title || 'Defaults to the post title'}
489
+ onChange={(e) => setSeoTitle(e.target.value)}
235
490
  />
236
- <button
237
- type="button"
238
- disabled={!canEdit}
239
- onClick={() => setMediaOpen(true)}
240
- 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"
241
- >
242
- Browse
243
- </button>
244
491
  </div>
245
- {featuredImage && (
246
- <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
247
- <img src={featuredImage} alt="" className="h-28 w-full object-cover" />
492
+
493
+ <div>
494
+ <div className="mb-1 flex items-center justify-between gap-2">
495
+ <div className="flex min-w-0 items-center gap-2">
496
+ <label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
497
+ SEO description
498
+ </label>
499
+ <AiFieldButton
500
+ label="Generate SEO description from content"
501
+ busy={aiBusy === 'seoDescription'}
502
+ disabled={!canEdit}
503
+ onClick={() => void handleAiGenerate('seoDescription')}
504
+ />
505
+ </div>
506
+ <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
248
507
  </div>
249
- )}
250
- </div>
251
- <div>
252
- <RichTextField
253
- label="Body"
254
- value={body}
255
- onChange={canEdit ? setBody : () => {}}
256
- helpText="Rendered by the Article Body section when its source is set to the post Body field."
257
- />
258
- </div>
259
- <div>
260
- <div className="mb-1 flex items-center justify-between">
261
- <label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
262
- SEO title
263
- </label>
264
- <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
265
- </div>
266
- <input
267
- id="pf-seo-title"
268
- className={INPUT}
269
- value={seoTitle}
270
- disabled={!canEdit}
271
- placeholder={title || 'Defaults to the post title'}
272
- onChange={(e) => setSeoTitle(e.target.value)}
273
- />
274
- </div>
275
- <div>
276
- <div className="mb-1 flex items-center justify-between">
277
- <label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
278
- SEO description
279
- </label>
280
- <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
508
+ <textarea
509
+ id="pf-seo-desc"
510
+ className={`${INPUT} min-h-[72px] resize-y`}
511
+ value={seoDescription}
512
+ disabled={!canEdit}
513
+ placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
514
+ onChange={(e) => setSeoDescription(e.target.value)}
515
+ />
281
516
  </div>
282
- <textarea
283
- id="pf-seo-desc"
284
- className={`${INPUT} min-h-[60px] resize-y`}
285
- value={seoDescription}
286
- disabled={!canEdit}
287
- placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
288
- onChange={(e) => setSeoDescription(e.target.value)}
289
- />
290
517
  </div>
291
- </div>
518
+ </Modal>
292
519
 
293
520
  <MediaPickerModal
294
521
  open={mediaOpen}
@@ -299,6 +526,17 @@ export function PostFieldsModal({
299
526
  }}
300
527
  accept="image/*"
301
528
  />
302
- </Modal>
529
+
530
+ <ConfirmDialog
531
+ open={confirmDiscard}
532
+ onClose={() => setConfirmDiscard(false)}
533
+ onConfirm={onClose}
534
+ title="Discard unsaved changes?"
535
+ description="You have unsaved post details. Closing now will discard them."
536
+ confirmLabel="Discard"
537
+ cancelLabel="Keep editing"
538
+ destructive
539
+ />
540
+ </>
303
541
  )
304
542
  }