@actuate-media/cms-admin 0.74.0 → 0.76.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 (97) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  3. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  4. package/dist/__tests__/views/editor-top-bar.render.test.js +12 -0
  5. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  6. package/dist/__tests__/views/post-fields-modal.render.test.js +5 -6
  7. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  8. package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
  9. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  10. package/dist/actuate-admin.css +1 -1
  11. package/dist/components/EditorSeoAside.d.ts +7 -4
  12. package/dist/components/EditorSeoAside.d.ts.map +1 -1
  13. package/dist/components/EditorSeoAside.js +7 -16
  14. package/dist/components/EditorSeoAside.js.map +1 -1
  15. package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
  16. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
  17. package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
  18. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
  19. package/dist/components/seo/SeoAccordion.d.ts +16 -0
  20. package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
  21. package/dist/components/seo/SeoAccordion.js +21 -0
  22. package/dist/components/seo/SeoAccordion.js.map +1 -0
  23. package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
  24. package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
  25. package/dist/components/seo/SeoAnalysisList.js +32 -0
  26. package/dist/components/seo/SeoAnalysisList.js.map +1 -0
  27. package/dist/components/seo/SeoEditorPane.d.ts +15 -4
  28. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  29. package/dist/components/seo/SeoEditorPane.js +131 -31
  30. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  31. package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
  32. package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
  33. package/dist/components/seo/SeoInsightsMetrics.js +16 -0
  34. package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
  35. package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
  36. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
  37. package/dist/components/seo/SeoLinkingSection.js +44 -0
  38. package/dist/components/seo/SeoLinkingSection.js.map +1 -0
  39. package/dist/lib/editor-seo-content.d.ts +16 -0
  40. package/dist/lib/editor-seo-content.d.ts.map +1 -0
  41. package/dist/lib/editor-seo-content.js +139 -0
  42. package/dist/lib/editor-seo-content.js.map +1 -0
  43. package/dist/lib/page-editor-service.d.ts +2 -0
  44. package/dist/lib/page-editor-service.d.ts.map +1 -1
  45. package/dist/lib/page-editor-service.js +4 -0
  46. package/dist/lib/page-editor-service.js.map +1 -1
  47. package/dist/lib/post-editor-service.d.ts +3 -0
  48. package/dist/lib/post-editor-service.d.ts.map +1 -1
  49. package/dist/lib/post-editor-service.js +4 -0
  50. package/dist/lib/post-editor-service.js.map +1 -1
  51. package/dist/lib/seo-service.d.ts +2 -0
  52. package/dist/lib/seo-service.d.ts.map +1 -1
  53. package/dist/lib/seo-service.js +5 -0
  54. package/dist/lib/seo-service.js.map +1 -1
  55. package/dist/views/page-editor/EditorTopBar.d.ts +9 -1
  56. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  57. package/dist/views/page-editor/EditorTopBar.js +38 -6
  58. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  59. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.js +47 -2
  61. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  62. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  63. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  64. package/dist/views/page-editor/PageSettingsModal.js +2 -13
  65. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  66. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  67. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  68. package/dist/views/post-editor/PostFieldsModal.js +17 -79
  69. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  70. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  71. package/dist/views/post-editor/PostSectionEditor.js +48 -1
  72. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  73. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  74. package/dist/views/seo/ContentTab.js +7 -1
  75. package/dist/views/seo/ContentTab.js.map +1 -1
  76. package/package.json +3 -3
  77. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  78. package/src/__tests__/views/editor-top-bar.render.test.tsx +24 -0
  79. package/src/__tests__/views/post-fields-modal.render.test.tsx +5 -9
  80. package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
  81. package/src/components/EditorSeoAside.tsx +27 -108
  82. package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
  83. package/src/components/seo/SeoAccordion.tsx +76 -0
  84. package/src/components/seo/SeoAnalysisList.tsx +113 -0
  85. package/src/components/seo/SeoEditorPane.tsx +497 -244
  86. package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
  87. package/src/components/seo/SeoLinkingSection.tsx +151 -0
  88. package/src/lib/editor-seo-content.ts +144 -0
  89. package/src/lib/page-editor-service.ts +8 -0
  90. package/src/lib/post-editor-service.ts +8 -0
  91. package/src/lib/seo-service.ts +7 -0
  92. package/src/views/page-editor/EditorTopBar.tsx +76 -4
  93. package/src/views/page-editor/PageSectionEditor.tsx +70 -0
  94. package/src/views/page-editor/PageSettingsModal.tsx +1 -45
  95. package/src/views/post-editor/PostFieldsModal.tsx +19 -138
  96. package/src/views/post-editor/PostSectionEditor.tsx +72 -0
  97. package/src/views/seo/ContentTab.tsx +11 -5
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
+ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
7
8
  import { EditorSeoAside } from '../../components/EditorSeoAside.js'
8
9
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
9
10
  import { VersionHistory } from '../../components/VersionHistory.js'
@@ -16,6 +17,7 @@ import {
16
17
  missingPageFields,
17
18
  moveSection,
18
19
  publishPage,
20
+ PAGES_COLLECTION,
19
21
  removeSection,
20
22
  reorderSections,
21
23
  savePageDraft,
@@ -45,6 +47,7 @@ import { AddSectionModal } from './AddSectionModal.js'
45
47
  import { PageSettingsModal } from './PageSettingsModal.js'
46
48
  import { ValidationSummary } from './ValidationSummary.js'
47
49
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
50
+ import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
48
51
 
49
52
  interface PageSectionEditorProps {
50
53
  /** undefined when creating a new page (`/pages/new`) */
@@ -102,6 +105,7 @@ export function PageSectionEditor({
102
105
  const [historyOpen, setHistoryOpen] = useState(false)
103
106
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
104
107
  const [confirmPublish, setConfirmPublish] = useState(false)
108
+ const [scheduleOpen, setScheduleOpen] = useState(false)
105
109
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
106
110
  const [validation, setValidation] = useState<ValidationResult | null>(null)
107
111
 
@@ -128,6 +132,17 @@ export function PageSectionEditor({
128
132
  [page],
129
133
  )
130
134
  const publishReady = Boolean(page) && publishCheck.errors.length === 0
135
+
136
+ const liveSeoScore = useMemo(() => {
137
+ if (!page) return 0
138
+ return computeLiveSeoScore({
139
+ title: page.title,
140
+ slug: page.slug,
141
+ seoTitle: page.seoTitle,
142
+ seoDescription: page.seoDescription,
143
+ sections: page.sections,
144
+ })
145
+ }, [page])
131
146
  const publishDisabledReason = publishCheck.errors[0]
132
147
 
133
148
  // ─── Load ──────────────────────────────────────────────────────────
@@ -312,6 +327,42 @@ export function PageSectionEditor({
312
327
  setConfirmPublish(true)
313
328
  }, [page])
314
329
 
330
+ const requestSchedule = useCallback(async () => {
331
+ if (!page) return
332
+ if (!page.id) {
333
+ toast.error('Save the page before scheduling')
334
+ return
335
+ }
336
+ const result = validatePage(page, true)
337
+ setValidation(result.errors.length || result.warnings.length ? result : null)
338
+ if (result.errors.length > 0) {
339
+ toast.error('Resolve the listed issues before scheduling')
340
+ return
341
+ }
342
+ if (dirty) {
343
+ const ok = await handleSaveDraft()
344
+ if (!ok) return
345
+ }
346
+ setScheduleOpen(true)
347
+ }, [page, dirty, handleSaveDraft])
348
+
349
+ const handleScheduled = useCallback(
350
+ (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
351
+ setPage((prev) =>
352
+ prev
353
+ ? {
354
+ ...prev,
355
+ status: next.status as EditorPage['status'],
356
+ scheduledAt: next.scheduledAt,
357
+ scheduledUnpublishAt: next.scheduledUnpublishAt,
358
+ }
359
+ : prev,
360
+ )
361
+ setValidation(null)
362
+ },
363
+ [],
364
+ )
365
+
315
366
  const doPublish = useCallback(async () => {
316
367
  if (!page) return
317
368
  setPublishing(true)
@@ -518,8 +569,13 @@ export function PageSectionEditor({
518
569
  onOpenSettings={() => setSettingsOpen(true)}
519
570
  onOpenSeo={handleToggleSeo}
520
571
  seoOpen={seoOpen}
572
+ seoScore={liveSeoScore}
521
573
  onSaveDraft={handleSaveDraft}
522
574
  onPublish={requestPublish}
575
+ onSchedule={page.id ? requestSchedule : undefined}
576
+ scheduleReady={Boolean(page.id)}
577
+ scheduledAt={page.scheduledAt}
578
+ scheduledUnpublishAt={page.scheduledUnpublishAt}
523
579
  structuralTypes={structuralTypes}
524
580
  />
525
581
 
@@ -622,8 +678,10 @@ export function PageSectionEditor({
622
678
  entityType="page"
623
679
  entityId={page.id}
624
680
  title={page.title}
681
+ slug={page.slug}
625
682
  seoTitle={page.seoTitle}
626
683
  seoDescription={page.seoDescription}
684
+ sections={page.sections}
627
685
  canEdit={effectiveCanEdit}
628
686
  onClose={() => setSeoOpen(false)}
629
687
  onDraftChange={handlePageSettings}
@@ -672,6 +730,18 @@ export function PageSectionEditor({
672
730
  confirmLabel="Publish"
673
731
  />
674
732
 
733
+ {page.id && (
734
+ <SchedulePublishDialog
735
+ collectionSlug={PAGES_COLLECTION}
736
+ documentId={page.id}
737
+ scheduledAt={page.scheduledAt}
738
+ scheduledUnpublishAt={page.scheduledUnpublishAt}
739
+ open={scheduleOpen}
740
+ onClose={() => setScheduleOpen(false)}
741
+ onScheduled={handleScheduled}
742
+ />
743
+ )}
744
+
675
745
  <ConfirmDialog
676
746
  open={leaveTarget !== null}
677
747
  onClose={() => setLeaveTarget(null)}
@@ -4,15 +4,9 @@ import { useEffect, useRef, useState } from 'react'
4
4
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
7
- import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
8
7
  import { cmsApi } from '../../lib/api.js'
9
8
  import type { EditorPage } from '../../lib/page-editor-service.js'
10
9
 
11
- const SEO_TITLE_MIN = 30
12
- const SEO_TITLE_MAX = 60
13
- const SEO_DESC_MIN = 120
14
- const SEO_DESC_MAX = 160
15
-
16
10
  interface PageSettingsModalProps {
17
11
  open: boolean
18
12
  page: EditorPage
@@ -32,7 +26,7 @@ interface ParentOption {
32
26
  title: string
33
27
  }
34
28
 
35
- /** Edits page-level fields: title, slug, path, parent page, and SEO. */
29
+ /** Edits page-level fields: title, slug, path, and parent page. SEO lives in the SEO tab. */
36
30
  export function PageSettingsModal({
37
31
  open,
38
32
  page,
@@ -44,8 +38,6 @@ export function PageSettingsModal({
44
38
  const [slug, setSlug] = useState(page.slug)
45
39
  const [path, setPath] = useState(page.path)
46
40
  const [parentPageId, setParentPageId] = useState(page.parentPageId ?? '')
47
- const [seoTitle, setSeoTitle] = useState(page.seoTitle)
48
- const [seoDescription, setSeoDescription] = useState(page.seoDescription)
49
41
  // Auto-derive slug/path from title until the user edits slug manually.
50
42
  const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
51
43
  const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
@@ -59,8 +51,6 @@ export function PageSettingsModal({
59
51
  setSlug(page.slug)
60
52
  setPath(page.path)
61
53
  setParentPageId(page.parentPageId ?? '')
62
- setSeoTitle(page.seoTitle)
63
- setSeoDescription(page.seoDescription)
64
54
  setSlugTouched(Boolean(page.slug))
65
55
  }, [open, page])
66
56
 
@@ -110,8 +100,6 @@ export function PageSettingsModal({
110
100
  slug,
111
101
  path: path || (slug ? `/${slug}` : '/'),
112
102
  parentPageId: parentPageId || null,
113
- seoTitle,
114
- seoDescription,
115
103
  })
116
104
  onClose()
117
105
  }
@@ -231,38 +219,6 @@ export function PageSettingsModal({
231
219
  Nests this page under a parent in the Pages tree view.
232
220
  </p>
233
221
  </div>
234
- <div>
235
- <div className="mb-1 flex items-center justify-between">
236
- <label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
237
- SEO title
238
- </label>
239
- <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
240
- </div>
241
- <input
242
- id="ps-seo-title"
243
- className={INPUT}
244
- value={seoTitle}
245
- disabled={!canEdit}
246
- placeholder={title || 'Defaults to the page title'}
247
- onChange={(e) => setSeoTitle(e.target.value)}
248
- />
249
- </div>
250
- <div>
251
- <div className="mb-1 flex items-center justify-between">
252
- <label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
253
- SEO description
254
- </label>
255
- <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
256
- </div>
257
- <textarea
258
- id="ps-seo-desc"
259
- className={`${INPUT} min-h-[64px] resize-y`}
260
- value={seoDescription}
261
- disabled={!canEdit}
262
- placeholder="Summarize the page for search results (120–160 characters)"
263
- onChange={(e) => setSeoDescription(e.target.value)}
264
- />
265
- </div>
266
222
  </form>
267
223
  </Modal>
268
224
  )
@@ -9,16 +9,9 @@ import { Button } from '../../components/ui/Button.js'
9
9
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
10
10
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
11
11
  import { TagInput } from '../../components/TagInput.js'
12
- import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
13
12
  import { coauthorText } from '../../lib/coauthor-client.js'
14
- import { generateSeoField } from '../../lib/seo-service.js'
15
13
  import type { EditorPost } from '../../lib/post-editor-service.js'
16
14
 
17
- const SEO_TITLE_MIN = 30
18
- const SEO_TITLE_MAX = 60
19
- const SEO_DESC_MIN = 120
20
- const SEO_DESC_MAX = 160
21
-
22
15
  /**
23
16
  * The slice of a collection's field config the modal reads to pick controls:
24
17
  * a `select` category renders a dropdown (structured vocabulary), an `array`
@@ -47,8 +40,6 @@ const INPUT =
47
40
 
48
41
  const slugify = normalizeSlug
49
42
 
50
- type AiTarget = 'excerpt' | 'seoTitle' | 'seoDescription'
51
-
52
43
  type FormSnapshot = {
53
44
  title: string
54
45
  slug: string
@@ -56,8 +47,6 @@ type FormSnapshot = {
56
47
  featuredImage: string
57
48
  category: string
58
49
  tags: string
59
- seoTitle: string
60
- seoDescription: string
61
50
  }
62
51
 
63
52
  function snapshotFromPost(post: EditorPost): FormSnapshot {
@@ -68,8 +57,6 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
68
57
  featuredImage: post.featuredImage,
69
58
  category: post.category,
70
59
  tags: [...post.tags].sort().join('\0'),
71
- seoTitle: post.seoTitle,
72
- seoDescription: post.seoDescription,
73
60
  }
74
61
  }
75
62
 
@@ -80,8 +67,6 @@ function snapshotFromFields(input: {
80
67
  featuredImage: string
81
68
  category: string
82
69
  tags: string[]
83
- seoTitle: string
84
- seoDescription: string
85
70
  }): FormSnapshot {
86
71
  return {
87
72
  ...input,
@@ -132,7 +117,7 @@ function AiFieldButton({
132
117
  )
133
118
  }
134
119
 
135
- /** Edits post metadata: title, slug, excerpt, featured image, category, tags, SEO. */
120
+ /** Edits post metadata: title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
136
121
  export function PostFieldsModal({
137
122
  open,
138
123
  post,
@@ -147,11 +132,9 @@ export function PostFieldsModal({
147
132
  const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
148
133
  const [category, setCategory] = useState(post.category)
149
134
  const [tags, setTags] = useState<string[]>(post.tags)
150
- const [seoTitle, setSeoTitle] = useState(post.seoTitle)
151
- const [seoDescription, setSeoDescription] = useState(post.seoDescription)
152
135
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
153
136
  const [mediaOpen, setMediaOpen] = useState(false)
154
- const [aiBusy, setAiBusy] = useState<AiTarget | null>(null)
137
+ const [aiBusy, setAiBusy] = useState(false)
155
138
  const [confirmDiscard, setConfirmDiscard] = useState(false)
156
139
  const baselineRef = useRef<FormSnapshot | null>(null)
157
140
  // Land the user in the Title field (name-it-first flow for new posts).
@@ -177,10 +160,8 @@ export function PostFieldsModal({
177
160
  setFeaturedImage(post.featuredImage)
178
161
  setCategory(post.category)
179
162
  setTags(post.tags)
180
- setSeoTitle(post.seoTitle)
181
- setSeoDescription(post.seoDescription)
182
163
  setSlugTouched(Boolean(post.slug))
183
- setAiBusy(null)
164
+ setAiBusy(false)
184
165
  baselineRef.current = snapshotFromPost(post)
185
166
  }, [open, post])
186
167
 
@@ -193,11 +174,9 @@ export function PostFieldsModal({
193
174
  featuredImage,
194
175
  category,
195
176
  tags,
196
- seoTitle,
197
- seoDescription,
198
177
  })
199
178
  return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
200
- }, [open, title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription])
179
+ }, [open, title, slug, excerpt, featuredImage, category, tags])
201
180
 
202
181
  function requestDismiss() {
203
182
  if (isDirty) {
@@ -214,7 +193,7 @@ export function PostFieldsModal({
214
193
 
215
194
  const slugError = validateSlug(slug)
216
195
 
217
- async function handleAiGenerate(target: AiTarget) {
196
+ async function handleAiGenerateExcerpt() {
218
197
  const context = buildContentContext(title, post.body, excerpt)
219
198
  if (!context.trim()) {
220
199
  toast.error(
@@ -223,77 +202,29 @@ export function PostFieldsModal({
223
202
  return
224
203
  }
225
204
 
226
- setAiBusy(target)
205
+ setAiBusy(true)
227
206
  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.')
207
+ const outcome = await coauthorText('compress', context, {
208
+ instructions:
209
+ 'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
210
+ targetLength: 45,
211
+ })
212
+ if (!outcome.ok) {
213
+ toast.error(outcome.error)
240
214
  return
241
215
  }
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.')
216
+ setExcerpt(outcome.result.text.trim())
217
+ toast.success('Excerpt generated — review before saving.')
287
218
  } catch {
288
219
  toast.error('AI request failed.')
289
220
  } finally {
290
- setAiBusy(null)
221
+ setAiBusy(false)
291
222
  }
292
223
  }
293
224
 
294
225
  function handleSave() {
295
226
  if (slugError) return
296
- onSave({ title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription })
227
+ onSave({ title, slug, excerpt, featuredImage, category, tags })
297
228
  onClose()
298
229
  }
299
230
 
@@ -421,9 +352,9 @@ export function PostFieldsModal({
421
352
  </label>
422
353
  <AiFieldButton
423
354
  label="Generate excerpt from content"
424
- busy={aiBusy === 'excerpt'}
355
+ busy={aiBusy}
425
356
  disabled={!canEdit}
426
- onClick={() => void handleAiGenerate('excerpt')}
357
+ onClick={() => void handleAiGenerateExcerpt()}
427
358
  />
428
359
  </div>
429
360
  <textarea
@@ -464,56 +395,6 @@ export function PostFieldsModal({
464
395
  </div>
465
396
  )}
466
397
  </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>
483
- <input
484
- id="pf-seo-title"
485
- className={INPUT}
486
- value={seoTitle}
487
- disabled={!canEdit}
488
- placeholder={title || 'Defaults to the post title'}
489
- onChange={(e) => setSeoTitle(e.target.value)}
490
- />
491
- </div>
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} />
507
- </div>
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
- />
516
- </div>
517
398
  </div>
518
399
  </Modal>
519
400
 
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { AlertTriangle, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
+ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
7
8
  import { EditorSeoAside } from '../../components/EditorSeoAside.js'
8
9
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
9
10
  import { VersionHistory } from '../../components/VersionHistory.js'
@@ -50,6 +51,7 @@ import { SectionInspector } from '../page-editor/SectionInspector.js'
50
51
  import { AddSectionModal } from '../page-editor/AddSectionModal.js'
51
52
  import { ValidationSummary } from '../page-editor/ValidationSummary.js'
52
53
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
54
+ import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
53
55
  import { PostEditorCanvas } from './PostEditorCanvas.js'
54
56
  import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
55
57
 
@@ -127,6 +129,7 @@ export function PostSectionEditor({
127
129
  const [historyOpen, setHistoryOpen] = useState(false)
128
130
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
129
131
  const [confirmPublish, setConfirmPublish] = useState(false)
132
+ const [scheduleOpen, setScheduleOpen] = useState(false)
130
133
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
131
134
  const [validation, setValidation] = useState<ValidationResult | null>(null)
132
135
 
@@ -158,6 +161,18 @@ export function PostSectionEditor({
158
161
  [post],
159
162
  )
160
163
  const publishReady = Boolean(post) && publishCheck.errors.length === 0
164
+
165
+ const liveSeoScore = useMemo(() => {
166
+ if (!post) return 0
167
+ return computeLiveSeoScore({
168
+ title: post.title,
169
+ slug: post.slug,
170
+ seoTitle: post.seoTitle,
171
+ seoDescription: post.seoDescription,
172
+ sections: post.sections,
173
+ bodyHtml: post.body,
174
+ })
175
+ }, [post])
161
176
  const publishDisabledReason = publishCheck.errors[0]
162
177
 
163
178
  // ─── Load ──────────────────────────────────────────────────────────
@@ -362,6 +377,43 @@ export function PostSectionEditor({
362
377
  setConfirmPublish(true)
363
378
  }, [post])
364
379
 
380
+ const requestSchedule = useCallback(async () => {
381
+ if (!post) return
382
+ if (!post.id) {
383
+ toast.error('Save the post before scheduling')
384
+ return
385
+ }
386
+ const result = validatePost(post, true)
387
+ setValidation(result.errors.length || result.warnings.length ? result : null)
388
+ if (result.errors.length > 0) {
389
+ toast.error('Resolve the listed issues before scheduling')
390
+ return
391
+ }
392
+ if (dirty) {
393
+ const ok = await handleSaveDraft()
394
+ if (!ok) return
395
+ }
396
+ setScheduleOpen(true)
397
+ }, [post, dirty, handleSaveDraft])
398
+
399
+ const handleScheduled = useCallback(
400
+ (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
401
+ setPost((prev) =>
402
+ prev
403
+ ? {
404
+ ...prev,
405
+ status: next.status as EditorPost['status'],
406
+ scheduledAt: next.scheduledAt,
407
+ scheduledUnpublishAt: next.scheduledUnpublishAt,
408
+ publishDate: next.scheduledAt ?? prev.publishDate,
409
+ }
410
+ : prev,
411
+ )
412
+ setValidation(null)
413
+ },
414
+ [],
415
+ )
416
+
365
417
  const doPublish = useCallback(async () => {
366
418
  if (!post) return
367
419
  setPublishing(true)
@@ -560,8 +612,13 @@ export function PostSectionEditor({
560
612
  onOpenSettings={() => setFieldsOpen(true)}
561
613
  onOpenSeo={handleToggleSeo}
562
614
  seoOpen={seoOpen}
615
+ seoScore={liveSeoScore}
563
616
  onSaveDraft={handleSaveDraft}
564
617
  onPublish={requestPublish}
618
+ onSchedule={post.id ? requestSchedule : undefined}
619
+ scheduleReady={Boolean(post.id)}
620
+ scheduledAt={post.scheduledAt}
621
+ scheduledUnpublishAt={post.scheduledUnpublishAt}
565
622
  structuralTypes={structuralTypes}
566
623
  />
567
624
 
@@ -645,8 +702,11 @@ export function PostSectionEditor({
645
702
  entityType="post"
646
703
  entityId={post.id}
647
704
  title={post.title}
705
+ slug={post.slug}
648
706
  seoTitle={post.seoTitle}
649
707
  seoDescription={post.seoDescription}
708
+ sections={post.sections}
709
+ bodyHtml={post.body}
650
710
  canEdit={canEdit}
651
711
  onClose={() => setSeoOpen(false)}
652
712
  onDraftChange={handleFields}
@@ -701,6 +761,18 @@ export function PostSectionEditor({
701
761
  confirmLabel="Publish"
702
762
  />
703
763
 
764
+ {post.id && (
765
+ <SchedulePublishDialog
766
+ collectionSlug={postType}
767
+ documentId={post.id}
768
+ scheduledAt={post.scheduledAt}
769
+ scheduledUnpublishAt={post.scheduledUnpublishAt}
770
+ open={scheduleOpen}
771
+ onClose={() => setScheduleOpen(false)}
772
+ onScheduled={handleScheduled}
773
+ />
774
+ )}
775
+
704
776
  <ConfirmDialog
705
777
  open={leaveTarget !== null}
706
778
  onClose={() => setLeaveTarget(null)}
@@ -34,7 +34,12 @@ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
34
34
  const inputCls =
35
35
  'w-full rounded-md border border-[var(--bdr)] bg-input-background py-2 pl-9 pr-3 text-base text-[var(--txt)] placeholder:text-[var(--sub)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]'
36
36
 
37
- type Selection = { type: string; id: string } | null
37
+ type SeoEntityType = 'page' | 'post'
38
+ type Selection = { type: SeoEntityType; id: string } | null
39
+
40
+ function parseEntityType(value: string | null | undefined): SeoEntityType | null {
41
+ return value === 'page' || value === 'post' ? value : null
42
+ }
38
43
 
39
44
  export function ContentTab({
40
45
  initialEntityType,
@@ -46,16 +51,17 @@ export function ContentTab({
46
51
  const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
47
52
  const [search, setSearch] = useState('')
48
53
  const [filter, setFilter] = useState<ContentSeoFilter>('all')
49
- const [selection, setSelection] = useState<Selection>(
50
- initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
51
- )
54
+ const [selection, setSelection] = useState<Selection>(() => {
55
+ const type = parseEntityType(initialEntityType)
56
+ return type && initialEntityId ? { type, id: initialEntityId } : null
57
+ })
52
58
 
53
59
  const rows = useMemo(
54
60
  () => applyContentSeoQuery(data ?? [], { search, filter }),
55
61
  [data, search, filter],
56
62
  )
57
63
 
58
- const toggleRow = (type: string, id: string) => {
64
+ const toggleRow = (type: SeoEntityType, id: string) => {
59
65
  setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
60
66
  }
61
67