@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
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import type { ReadabilityResult } from '@actuate-media/cms-core/seo/analysis'
4
+ import { prominentWordsFromText } from '../../lib/editor-seo-content.js'
5
+
6
+ export function SeoInsightsMetrics({
7
+ readability,
8
+ plainText,
9
+ }: {
10
+ readability: ReadabilityResult
11
+ plainText: string
12
+ }) {
13
+ const prominent = prominentWordsFromText(plainText)
14
+ const needsMoreCopy = readability.wordCount < 50
15
+
16
+ return (
17
+ <div className="space-y-4">
18
+ <div>
19
+ <h4 className="mb-1.5 text-[12.5px] font-medium text-[var(--txt)]">Prominent words</h4>
20
+ {needsMoreCopy ? (
21
+ <p className="text-[11.5px] text-[var(--sub)]">
22
+ Once you add more copy, we&apos;ll list the words that occur most in the content — a
23
+ quick read on what the page focuses on.
24
+ </p>
25
+ ) : prominent.length > 0 ? (
26
+ <div className="flex flex-wrap gap-1.5">
27
+ {prominent.map((word) => (
28
+ <span
29
+ key={word}
30
+ className="rounded-full border border-[var(--bdr)] bg-[var(--card)] px-2 py-0.5 text-[11.5px] text-[var(--txt)]"
31
+ >
32
+ {word}
33
+ </span>
34
+ ))}
35
+ </div>
36
+ ) : (
37
+ <p className="text-[11.5px] text-[var(--sub)]">No recurring terms yet.</p>
38
+ )}
39
+ </div>
40
+
41
+ <div className="grid grid-cols-2 gap-3">
42
+ <MetricCard
43
+ label="Reading time"
44
+ value={String(readability.readingTimeMinutes)}
45
+ unit="minutes"
46
+ hint={
47
+ needsMoreCopy
48
+ ? 'Continue writing to estimate reading time.'
49
+ : `${readability.wordCount} words at ~200 wpm`
50
+ }
51
+ />
52
+ <MetricCard
53
+ label="Word count"
54
+ value={String(readability.wordCount)}
55
+ unit="words"
56
+ hint={needsMoreCopy ? 'Add body content to unlock metrics.' : undefined}
57
+ />
58
+ </div>
59
+
60
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
61
+ <div className="mb-1 text-[12.5px] font-medium text-[var(--txt)]">Flesch reading ease</div>
62
+ {needsMoreCopy ? (
63
+ <p className="text-[11.5px] text-[var(--sub)]">
64
+ Your text should be slightly longer to calculate readability. Keep writing to unlock
65
+ this score.
66
+ </p>
67
+ ) : (
68
+ <>
69
+ <div className="flex items-baseline gap-2">
70
+ <span className="text-2xl font-medium text-[var(--acc)]">
71
+ {readability.fleschScore}
72
+ </span>
73
+ <span className="text-[11.5px] text-[var(--muted)]">out of 100</span>
74
+ </div>
75
+ <p className="mt-1 text-[11.5px] text-[var(--sub)]">
76
+ {readability.fleschLabel} —{' '}
77
+ {readability.fleschScore >= 60
78
+ ? 'Content is fairly easy to read.'
79
+ : 'Try shorter sentences and simpler words.'}
80
+ </p>
81
+ </>
82
+ )}
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ function MetricCard({
89
+ label,
90
+ value,
91
+ unit,
92
+ hint,
93
+ }: {
94
+ label: string
95
+ value: string
96
+ unit: string
97
+ hint?: string
98
+ }) {
99
+ return (
100
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
101
+ <div className="text-[11.5px] font-medium text-[var(--sub)]">{label}</div>
102
+ <div className="mt-1 flex items-baseline gap-1">
103
+ <span className="text-xl font-medium text-[var(--acc)]">{value}</span>
104
+ <span className="text-[11px] text-[var(--muted)]">{unit}</span>
105
+ </div>
106
+ {hint && <p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>}
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,151 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Link2, Loader2, Sparkles } from 'lucide-react'
5
+ import { fetchLinkSuggestions, type LinkSuggestion } from '../../lib/seo-service.js'
6
+
7
+ export function SeoLinkingSection({
8
+ entityType,
9
+ entityId,
10
+ internalLinks,
11
+ externalLinks,
12
+ internalLinkOk,
13
+ externalLinkOk,
14
+ }: {
15
+ entityType: 'page' | 'post'
16
+ entityId: string | null
17
+ internalLinks: number
18
+ externalLinks: number
19
+ internalLinkOk: boolean
20
+ externalLinkOk: boolean
21
+ }) {
22
+ const [loading, setLoading] = useState(false)
23
+ const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ useEffect(() => {
27
+ if (!entityId) {
28
+ setSuggestions([])
29
+ setError(null)
30
+ return
31
+ }
32
+ let alive = true
33
+ setLoading(true)
34
+ setError(null)
35
+ fetchLinkSuggestions({ targetEntityType: entityType, targetEntityId: entityId, limit: 5 })
36
+ .then((res) => {
37
+ if (!alive) return
38
+ if (res.error) {
39
+ setError(res.error)
40
+ setSuggestions([])
41
+ return
42
+ }
43
+ setSuggestions(res.data?.suggestions ?? [])
44
+ })
45
+ .finally(() => alive && setLoading(false))
46
+ return () => {
47
+ alive = false
48
+ }
49
+ }, [entityType, entityId])
50
+
51
+ return (
52
+ <div className="space-y-4">
53
+ <div className="grid grid-cols-2 gap-3">
54
+ <LinkCountCard
55
+ label="Internal links"
56
+ count={internalLinks}
57
+ ok={internalLinkOk}
58
+ hint={
59
+ internalLinkOk
60
+ ? 'Links to other pages on your site.'
61
+ : 'Add links to related content — aim for at least one.'
62
+ }
63
+ />
64
+ <LinkCountCard
65
+ label="Outbound links"
66
+ count={externalLinks}
67
+ ok={externalLinkOk}
68
+ hint={
69
+ externalLinkOk
70
+ ? 'Links to external sources.'
71
+ : 'Consider linking to authoritative external sources.'
72
+ }
73
+ />
74
+ </div>
75
+
76
+ {!entityId ? (
77
+ <p className="rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-3 text-[11.5px] text-[var(--sub)]">
78
+ Save a draft first to load AI link opportunities for this page.
79
+ </p>
80
+ ) : loading ? (
81
+ <div className="flex items-center gap-2 text-[12px] text-[var(--muted)]">
82
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
83
+ Finding link opportunities…
84
+ </div>
85
+ ) : error ? (
86
+ <p className="text-[11.5px] text-[var(--err)]">{error}</p>
87
+ ) : suggestions.length > 0 ? (
88
+ <div>
89
+ <div className="mb-2 flex items-center gap-1.5 text-[12px] font-medium text-[var(--txt)]">
90
+ <Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
91
+ Link opportunities
92
+ </div>
93
+ <ul className="space-y-2">
94
+ {suggestions.map((s) => (
95
+ <li
96
+ key={`${s.sourceEntityId}-${s.anchorText}`}
97
+ className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
98
+ >
99
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
100
+ <p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
101
+ <p className="mt-1 text-[11.5px] text-[var(--sub)]">
102
+ Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
103
+ </p>
104
+ {s.excerpt && (
105
+ <p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
106
+ )}
107
+ </li>
108
+ ))}
109
+ </ul>
110
+ </div>
111
+ ) : (
112
+ <p className="text-[11.5px] text-[var(--sub)]">
113
+ No inbound link suggestions yet. Run the site link index from SEO → Links to improve AI
114
+ recommendations.
115
+ </p>
116
+ )}
117
+
118
+ <div className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-2.5 text-[11px] text-[var(--sub)]">
119
+ <Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
120
+ <span>
121
+ Best practice: mix internal links (site structure + crawl paths) with selective outbound
122
+ links (trust signals). We flag counts here; apply links in the content editor.
123
+ </span>
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ function LinkCountCard({
130
+ label,
131
+ count,
132
+ ok,
133
+ hint,
134
+ }: {
135
+ label: string
136
+ count: number
137
+ ok: boolean
138
+ hint: string
139
+ }) {
140
+ return (
141
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
142
+ <div className="flex items-center justify-between gap-2">
143
+ <span className="text-[11.5px] font-medium text-[var(--sub)]">{label}</span>
144
+ <span className={`text-lg font-medium ${ok ? 'text-[var(--suc)]' : 'text-[var(--warn)]'}`}>
145
+ {count}
146
+ </span>
147
+ </div>
148
+ <p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,144 @@
1
+ import { getSectionType, type PageSection } from '@actuate-media/cms-core/page-sections'
2
+ import { analyzeContent } from '@actuate-media/cms-core/seo/analysis'
3
+
4
+ const HTML_LIKE = /<\/?[a-z][\s\S]*>/i
5
+
6
+ /** Build an HTML-ish string from editor sections (+ optional post body) for SEO analysis. */
7
+ export function buildEditorContentHtml(sections: PageSection[], body = ''): string {
8
+ const parts: string[] = []
9
+ if (body.trim()) parts.push(body)
10
+ for (const section of sections) {
11
+ if (section.visible === false) continue
12
+ const chunk = extractSectionContentHtml(section)
13
+ if (chunk) parts.push(chunk)
14
+ }
15
+ return parts.join('\n')
16
+ }
17
+
18
+ /** Live 0–100 SEO score from the current in-editor document state. */
19
+ export function computeLiveSeoScore(input: {
20
+ title: string
21
+ slug: string
22
+ seoTitle: string
23
+ seoDescription: string
24
+ focusKeyword?: string
25
+ sections: PageSection[]
26
+ bodyHtml?: string
27
+ }): number {
28
+ const content = buildEditorContentHtml(input.sections, input.bodyHtml ?? '')
29
+ return analyzeContent({
30
+ title: input.title,
31
+ slug: input.slug,
32
+ content,
33
+ metaTitle: input.seoTitle,
34
+ metaDescription: input.seoDescription,
35
+ focusKeyphrase: input.focusKeyword,
36
+ }).score
37
+ }
38
+
39
+ function extractSectionContentHtml(section: PageSection): string {
40
+ const content = section.content
41
+ if (!content || typeof content !== 'object') return ''
42
+ const def = getSectionType(section.sectionType)
43
+ const chunks: string[] = []
44
+
45
+ if (def) {
46
+ for (const field of def.fields) {
47
+ const val = content[field.key]
48
+ if (typeof val !== 'string' || !val.trim()) continue
49
+ if (field.type === 'richText' || HTML_LIKE.test(val)) {
50
+ chunks.push(val)
51
+ } else if (/heading|title|subtitle|eyebrow/i.test(field.key)) {
52
+ chunks.push(`<h2>${escapeHtml(val.trim())}</h2>`)
53
+ } else if (/body|content|text|copy|description|excerpt|quote|answer/i.test(field.key)) {
54
+ chunks.push(`<p>${escapeHtml(val.trim())}</p>`)
55
+ }
56
+ }
57
+ } else {
58
+ collectStringValues(content, chunks, 0)
59
+ }
60
+
61
+ return chunks.join('\n')
62
+ }
63
+
64
+ function collectStringValues(obj: Record<string, unknown>, chunks: string[], depth: number): void {
65
+ if (depth > 6) return
66
+ for (const value of Object.values(obj)) {
67
+ if (typeof value === 'string' && value.trim()) {
68
+ chunks.push(HTML_LIKE.test(value) ? value : `<p>${escapeHtml(value.trim())}</p>`)
69
+ } else if (Array.isArray(value)) {
70
+ for (const item of value) {
71
+ if (item && typeof item === 'object') {
72
+ collectStringValues(item as Record<string, unknown>, chunks, depth + 1)
73
+ }
74
+ }
75
+ } else if (value && typeof value === 'object') {
76
+ collectStringValues(value as Record<string, unknown>, chunks, depth + 1)
77
+ }
78
+ }
79
+ }
80
+
81
+ function escapeHtml(text: string): string {
82
+ return text
83
+ .replace(/&/g, '&amp;')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;')
86
+ .replace(/"/g, '&quot;')
87
+ }
88
+
89
+ const STOP_WORDS = new Set([
90
+ 'about',
91
+ 'after',
92
+ 'also',
93
+ 'been',
94
+ 'before',
95
+ 'being',
96
+ 'between',
97
+ 'both',
98
+ 'could',
99
+ 'each',
100
+ 'from',
101
+ 'have',
102
+ 'into',
103
+ 'more',
104
+ 'most',
105
+ 'other',
106
+ 'over',
107
+ 'some',
108
+ 'such',
109
+ 'than',
110
+ 'that',
111
+ 'their',
112
+ 'them',
113
+ 'then',
114
+ 'there',
115
+ 'these',
116
+ 'they',
117
+ 'this',
118
+ 'through',
119
+ 'under',
120
+ 'very',
121
+ 'were',
122
+ 'what',
123
+ 'when',
124
+ 'where',
125
+ 'which',
126
+ 'while',
127
+ 'with',
128
+ 'would',
129
+ 'your',
130
+ ])
131
+
132
+ /** Top content words for the Insights accordion (excluding common stop words). */
133
+ export function prominentWordsFromText(plainText: string, limit = 8): string[] {
134
+ const counts = new Map<string, number>()
135
+ for (const raw of plainText.toLowerCase().split(/\s+/)) {
136
+ const word = raw.replace(/[^a-z0-9'-]/g, '')
137
+ if (word.length < 4 || STOP_WORDS.has(word)) continue
138
+ counts.set(word, (counts.get(word) ?? 0) + 1)
139
+ }
140
+ return [...counts.entries()]
141
+ .sort((a, b) => b[1] - a[1])
142
+ .slice(0, limit)
143
+ .map(([word]) => word)
144
+ }
@@ -75,6 +75,8 @@ export interface EditorPage {
75
75
  seoDescription: string
76
76
  publishedAt: string | null
77
77
  updatedAt: string | null
78
+ scheduledAt: string | null
79
+ scheduledUnpublishAt: string | null
78
80
  /**
79
81
  * True when the document was authored in the visual Page Builder (it carries
80
82
  * a `_layout` tree). Such a page's `sections` are *compiled* from the layout
@@ -176,6 +178,8 @@ interface DocumentApiRow {
176
178
  slug: string | null
177
179
  status: PageStatus
178
180
  publishedAt: string | null
181
+ scheduledAt?: string | null
182
+ scheduledUnpublishAt?: string | null
179
183
  updatedAt: string | null
180
184
  data: Record<string, unknown> | null
181
185
  /** The page-builder tree, lifted out of `data` onto the wire envelope by the API. */
@@ -207,6 +211,8 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
207
211
  seoDescription: typeof data.metaDescription === 'string' ? data.metaDescription : '',
208
212
  publishedAt: doc.publishedAt ?? null,
209
213
  updatedAt: doc.updatedAt ?? null,
214
+ scheduledAt: doc.scheduledAt ?? null,
215
+ scheduledUnpublishAt: doc.scheduledUnpublishAt ?? null,
210
216
  builderOwned,
211
217
  }
212
218
  }
@@ -302,6 +308,8 @@ export function emptyPage(parentPageId: string | null = null): EditorPage {
302
308
  seoDescription: '',
303
309
  publishedAt: null,
304
310
  updatedAt: null,
311
+ scheduledAt: null,
312
+ scheduledUnpublishAt: null,
305
313
  builderOwned: false,
306
314
  }
307
315
  }
@@ -78,6 +78,9 @@ export interface EditorPost {
78
78
  tags: string[]
79
79
  status: PostStatus
80
80
  publishDate: string | null
81
+ /** When set and status is SCHEDULED, the cron publishes at this time. */
82
+ scheduledAt: string | null
83
+ scheduledUnpublishAt: string | null
81
84
  sections: PageSection[]
82
85
  seoTitle: string
83
86
  seoDescription: string
@@ -189,6 +192,7 @@ interface DocumentApiRow {
189
192
  status: PostStatus
190
193
  publishedAt: string | null
191
194
  scheduledAt?: string | null
195
+ scheduledUnpublishAt?: string | null
192
196
  updatedAt: string | null
193
197
  data: Record<string, unknown> | null
194
198
  }
@@ -235,6 +239,8 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
235
239
  tags: parseTags(data.tags),
236
240
  status: doc.status ?? 'DRAFT',
237
241
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
242
+ scheduledAt: doc.scheduledAt ?? null,
243
+ scheduledUnpublishAt: doc.scheduledUnpublishAt ?? null,
238
244
  // Preserve unknown (externally-managed) sections so the editor can show
239
245
  // them read-only and round-trip them on save instead of dropping them.
240
246
  sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
@@ -417,6 +423,8 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
417
423
  tags: [],
418
424
  status: 'DRAFT',
419
425
  publishDate: null,
426
+ scheduledAt: null,
427
+ scheduledUnpublishAt: null,
420
428
  sections,
421
429
  seoTitle: '',
422
430
  seoDescription: '',
@@ -9,6 +9,7 @@
9
9
  * `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
10
10
  */
11
11
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
12
+ import type { SEOAnalysisResult } from '@actuate-media/cms-core/seo/analysis'
12
13
  import { cmsApi } from './api.js'
13
14
 
14
15
  /**
@@ -239,6 +240,12 @@ export interface SeoFieldPayload {
239
240
  llmsOptional?: boolean
240
241
  }
241
242
 
243
+ export async function fetchDocumentSeoAnalysis(documentId: string): Promise<SEOAnalysisResult> {
244
+ const res = await cmsApi<SEOAnalysisResult>(`/seo/analysis/${encodeURIComponent(documentId)}`)
245
+ throwIfError(res)
246
+ return res.data ?? { score: 0, checks: [] }
247
+ }
248
+
242
249
  export async function updateSeoFields(
243
250
  entityType: string,
244
251
  entityId: string,
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ Calendar,
4
5
  Check,
5
6
  ExternalLink,
6
7
  Eye,
@@ -10,12 +11,12 @@ import {
10
11
  Loader2,
11
12
  type LucideIcon,
12
13
  Monitor,
13
- Search,
14
14
  Settings2,
15
15
  Smartphone,
16
16
  Tablet,
17
17
  TriangleAlert,
18
18
  } from 'lucide-react'
19
+ import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
19
20
  import type { PageStatus } from '../../lib/page-editor-service.js'
20
21
  import { VIEWPORTS, type ViewportId } from './viewports.js'
21
22
 
@@ -61,8 +62,16 @@ interface EditorTopBarProps {
61
62
  /** Open the document SEO panel (meta, social, schema, analysis). */
62
63
  onOpenSeo?: () => void
63
64
  seoOpen?: boolean
65
+ /** Live 0–100 SEO score shown left of the SEO label. */
66
+ seoScore?: number
64
67
  onSaveDraft: () => void
65
68
  onPublish: () => void
69
+ /** Open the schedule-publish dialog. Omitted when the document is unsaved. */
70
+ onSchedule?: () => void
71
+ /** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
72
+ scheduleReady?: boolean
73
+ scheduledAt?: string | null
74
+ scheduledUnpublishAt?: string | null
66
75
  /**
67
76
  * Section types the canvas renders as placeholders (custom / unmanaged).
68
77
  * When non-empty the editor is in "structure" mode: the eye preview is
@@ -108,8 +117,13 @@ export function EditorTopBar({
108
117
  onOpenSettings,
109
118
  onOpenSeo,
110
119
  seoOpen = false,
120
+ seoScore = 0,
111
121
  onSaveDraft,
112
122
  onPublish,
123
+ onSchedule,
124
+ scheduleReady = true,
125
+ scheduledAt = null,
126
+ scheduledUnpublishAt = null,
113
127
  structuralTypes = [],
114
128
  }: EditorTopBarProps) {
115
129
  const statusInfo = STATUS_TEXT[status]
@@ -132,6 +146,26 @@ export function EditorTopBar({
132
146
  ? 'You do not have permission to publish'
133
147
  : undefined
134
148
 
149
+ const scheduleBlocked =
150
+ !onSchedule ||
151
+ !canPublish ||
152
+ !publishReady ||
153
+ !scheduleReady ||
154
+ publishing ||
155
+ saveState === 'saving'
156
+ const scheduleTitle =
157
+ saveState === 'saving'
158
+ ? 'Wait for the current save to finish'
159
+ : !scheduleReady
160
+ ? 'Save before scheduling'
161
+ : canPublish && !publishReady && publishDisabledReason
162
+ ? publishDisabledReason
163
+ : !canPublish
164
+ ? 'You do not have permission to schedule'
165
+ : scheduledAt
166
+ ? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
167
+ : 'Schedule a future publish date'
168
+
135
169
  return (
136
170
  <header className="bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
137
171
  {/* Breadcrumb + status */}
@@ -155,6 +189,18 @@ export function EditorTopBar({
155
189
  >
156
190
  {statusInfo.label}
157
191
  </span>
192
+ {scheduledAt && (
193
+ <span
194
+ className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
195
+ title={
196
+ scheduledUnpublishAt
197
+ ? `Publishes ${formatScheduleLabel(scheduledAt)} · Unpublishes ${formatScheduleLabel(scheduledUnpublishAt)}`
198
+ : `Publishes ${formatScheduleLabel(scheduledAt)}`
199
+ }
200
+ >
201
+ · {formatScheduleLabel(scheduledAt)}
202
+ </span>
203
+ )}
158
204
  <SaveStatus state={saveState} dirty={dirty} />
159
205
  </div>
160
206
 
@@ -203,15 +249,15 @@ export function EditorTopBar({
203
249
  type="button"
204
250
  onClick={onOpenSeo}
205
251
  aria-pressed={seoOpen}
206
- aria-label="SEO"
207
- title="SEO — meta tags, social preview, schema, and analysis"
252
+ aria-label={`SEO, score ${seoScore} out of 100`}
253
+ title={`SEO — score ${seoScore}/100. Meta tags, social preview, schema, and analysis.`}
208
254
  className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors ${
209
255
  seoOpen
210
256
  ? 'bg-primary text-primary-foreground'
211
257
  : 'text-muted-foreground hover:text-foreground hover:bg-accent'
212
258
  }`}
213
259
  >
214
- <Search className="h-4 w-4 shrink-0" aria-hidden />
260
+ <EditorSeoScoreBadge score={seoScore} inverted={seoOpen} />
215
261
  <span className="hidden sm:inline">SEO</span>
216
262
  </button>
217
263
  )}
@@ -291,6 +337,20 @@ export function EditorTopBar({
291
337
  {saveState === 'saving' ? 'Saving…' : 'Save Draft'}
292
338
  </button>
293
339
 
340
+ {onSchedule && (
341
+ <button
342
+ type="button"
343
+ onClick={onSchedule}
344
+ disabled={scheduleBlocked}
345
+ title={scheduleTitle}
346
+ aria-label={scheduledAt ? 'Reschedule publish' : 'Schedule publish'}
347
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
348
+ >
349
+ <Calendar className="h-3.5 w-3.5" aria-hidden />
350
+ <span className="hidden sm:inline">{scheduledAt ? 'Reschedule' : 'Schedule'}</span>
351
+ </button>
352
+ )}
353
+
294
354
  <button
295
355
  type="button"
296
356
  onClick={onPublish}
@@ -350,3 +410,15 @@ function SaveStatus({ state, dirty }: { state: SaveState; dirty: boolean }) {
350
410
  }
351
411
  return null
352
412
  }
413
+
414
+ function formatScheduleLabel(iso: string): string {
415
+ const d = new Date(iso)
416
+ if (Number.isNaN(d.getTime())) return iso
417
+ return d.toLocaleString(undefined, {
418
+ month: 'short',
419
+ day: 'numeric',
420
+ year: 'numeric',
421
+ hour: 'numeric',
422
+ minute: '2-digit',
423
+ })
424
+ }