@actuate-media/cms-admin 0.73.0 → 0.75.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 (209) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/LoginRoot.d.ts.map +1 -1
  6. package/dist/LoginRoot.js +2 -1
  7. package/dist/LoginRoot.js.map +1 -1
  8. package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
  9. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  11. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  12. package/dist/__tests__/views/portal-container.test.js +13 -0
  13. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  14. package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
  15. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  16. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  17. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-content-pane.render.test.js +15 -4
  19. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/CoauthorResultPopover.js +1 -1
  22. package/dist/components/EditorSeoAside.d.ts +23 -0
  23. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  24. package/dist/components/EditorSeoAside.js +21 -0
  25. package/dist/components/EditorSeoAside.js.map +1 -0
  26. package/dist/components/MediaPickerModal.js +1 -1
  27. package/dist/components/SEOConfigPanel.js +1 -1
  28. package/dist/components/SchedulePublishDialog.js +1 -1
  29. package/dist/components/SharePreviewLinkDialog.js +1 -1
  30. package/dist/components/ThemeProvider.d.ts.map +1 -1
  31. package/dist/components/ThemeProvider.js +2 -1
  32. package/dist/components/ThemeProvider.js.map +1 -1
  33. package/dist/components/VersionHistory.js +1 -1
  34. package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
  35. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
  36. package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
  37. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
  38. package/dist/components/seo/SeoAccordion.d.ts +16 -0
  39. package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
  40. package/dist/components/seo/SeoAccordion.js +21 -0
  41. package/dist/components/seo/SeoAccordion.js.map +1 -0
  42. package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
  43. package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
  44. package/dist/components/seo/SeoAnalysisList.js +32 -0
  45. package/dist/components/seo/SeoAnalysisList.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +15 -4
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  48. package/dist/components/seo/SeoEditorPane.js +155 -35
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  50. package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
  51. package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
  52. package/dist/components/seo/SeoInsightsMetrics.js +16 -0
  53. package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
  54. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  55. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  56. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  57. package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
  58. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
  59. package/dist/components/seo/SeoLinkingSection.js +44 -0
  60. package/dist/components/seo/SeoLinkingSection.js.map +1 -0
  61. package/dist/components/seo/primitives.d.ts.map +1 -1
  62. package/dist/components/seo/primitives.js +3 -3
  63. package/dist/components/seo/primitives.js.map +1 -1
  64. package/dist/components/ui/Card.js +5 -5
  65. package/dist/components/ui/Card.js.map +1 -1
  66. package/dist/components/ui/InspectorPane.js +3 -3
  67. package/dist/components/ui/InspectorPane.js.map +1 -1
  68. package/dist/components/ui/PanelSection.d.ts +16 -0
  69. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  70. package/dist/components/ui/PanelSection.js +15 -0
  71. package/dist/components/ui/PanelSection.js.map +1 -0
  72. package/dist/lib/admin-root-element.d.ts +9 -0
  73. package/dist/lib/admin-root-element.d.ts.map +1 -0
  74. package/dist/lib/admin-root-element.js +14 -0
  75. package/dist/lib/admin-root-element.js.map +1 -0
  76. package/dist/lib/editor-seo-content.d.ts +16 -0
  77. package/dist/lib/editor-seo-content.d.ts.map +1 -0
  78. package/dist/lib/editor-seo-content.js +139 -0
  79. package/dist/lib/editor-seo-content.js.map +1 -0
  80. package/dist/lib/portal-container.d.ts.map +1 -1
  81. package/dist/lib/portal-container.js +2 -3
  82. package/dist/lib/portal-container.js.map +1 -1
  83. package/dist/lib/seo-service.d.ts +91 -0
  84. package/dist/lib/seo-service.d.ts.map +1 -1
  85. package/dist/lib/seo-service.js +55 -2
  86. package/dist/lib/seo-service.js.map +1 -1
  87. package/dist/views/ApiKeys.js +2 -2
  88. package/dist/views/Dashboard.d.ts.map +1 -1
  89. package/dist/views/Dashboard.js +12 -14
  90. package/dist/views/Dashboard.js.map +1 -1
  91. package/dist/views/MediaBrowser.d.ts.map +1 -1
  92. package/dist/views/MediaBrowser.js +35 -6
  93. package/dist/views/MediaBrowser.js.map +1 -1
  94. package/dist/views/PageEditor.js +1 -1
  95. package/dist/views/Posts/PostTypeEditor.js +3 -3
  96. package/dist/views/SEO.d.ts.map +1 -1
  97. package/dist/views/SEO.js +4 -2
  98. package/dist/views/SEO.js.map +1 -1
  99. package/dist/views/ScriptTags.js +1 -1
  100. package/dist/views/ScriptTags.js.map +1 -1
  101. package/dist/views/Users.js +1 -1
  102. package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
  103. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  104. package/dist/views/page-editor/EditorTopBar.js +5 -2
  105. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  106. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  107. package/dist/views/page-editor/PageSectionEditor.js +36 -1
  108. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  109. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  110. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  111. package/dist/views/page-editor/PageSettingsModal.js +2 -13
  112. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  113. package/dist/views/page-editor/SectionInspector.js +2 -2
  114. package/dist/views/page-editor/SectionsPanel.js +1 -1
  115. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  116. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  117. package/dist/views/post-editor/PostFieldsModal.js +17 -79
  118. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  119. package/dist/views/post-editor/PostHeaderPanel.js +1 -1
  120. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  121. package/dist/views/post-editor/PostSectionEditor.js +37 -1
  122. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  123. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  124. package/dist/views/seo/ContentTab.js +7 -1
  125. package/dist/views/seo/ContentTab.js.map +1 -1
  126. package/dist/views/seo/LinksTab.d.ts +4 -0
  127. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  128. package/dist/views/seo/LinksTab.js +111 -0
  129. package/dist/views/seo/LinksTab.js.map +1 -0
  130. package/dist/views/seo/OverviewTab.js +1 -1
  131. package/dist/views/seo/OverviewTab.js.map +1 -1
  132. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  133. package/dist/views/seo/RedirectsTab.js +39 -6
  134. package/dist/views/seo/RedirectsTab.js.map +1 -1
  135. package/dist/views/seo/TechnicalTab.js +2 -2
  136. package/dist/views/settings/AISettingsTab.js +1 -1
  137. package/dist/views/settings/AiFeaturesCard.js +1 -1
  138. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  139. package/dist/views/settings/AppearanceTab.js +1 -1
  140. package/dist/views/settings/BrandVoiceCard.js +1 -1
  141. package/dist/views/settings/EmailSettingsTab.js +2 -2
  142. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  143. package/dist/views/settings/SecurityStatusCard.js +1 -1
  144. package/dist/views/settings/SeoSettingsTab.js +4 -4
  145. package/dist/views/settings/UpdatesTab.js +2 -2
  146. package/dist/views/settings/components.js +3 -3
  147. package/dist/views/settings/components.js.map +1 -1
  148. package/package.json +2 -2
  149. package/src/AdminRoot.tsx +3 -2
  150. package/src/LoginRoot.tsx +2 -1
  151. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  152. package/src/__tests__/views/portal-container.test.tsx +14 -0
  153. package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
  154. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  155. package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
  156. package/src/components/CoauthorResultPopover.tsx +1 -1
  157. package/src/components/EditorSeoAside.tsx +68 -0
  158. package/src/components/MediaPickerModal.tsx +1 -1
  159. package/src/components/SEOConfigPanel.tsx +2 -2
  160. package/src/components/SchedulePublishDialog.tsx +1 -1
  161. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  162. package/src/components/ThemeProvider.tsx +3 -1
  163. package/src/components/VersionHistory.tsx +1 -1
  164. package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
  165. package/src/components/seo/SeoAccordion.tsx +76 -0
  166. package/src/components/seo/SeoAnalysisList.tsx +113 -0
  167. package/src/components/seo/SeoEditorPane.tsx +532 -250
  168. package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
  169. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  170. package/src/components/seo/SeoLinkingSection.tsx +151 -0
  171. package/src/components/seo/primitives.tsx +7 -3
  172. package/src/components/ui/Card.tsx +5 -5
  173. package/src/components/ui/InspectorPane.tsx +3 -3
  174. package/src/components/ui/PanelSection.tsx +53 -0
  175. package/src/lib/admin-root-element.ts +15 -0
  176. package/src/lib/editor-seo-content.ts +144 -0
  177. package/src/lib/portal-container.ts +3 -2
  178. package/src/lib/seo-service.ts +150 -2
  179. package/src/views/ApiKeys.tsx +2 -2
  180. package/src/views/Dashboard.tsx +40 -53
  181. package/src/views/MediaBrowser.tsx +62 -12
  182. package/src/views/PageEditor.tsx +1 -1
  183. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  184. package/src/views/SEO.tsx +6 -0
  185. package/src/views/ScriptTags.tsx +2 -2
  186. package/src/views/Users.tsx +1 -1
  187. package/src/views/page-editor/EditorTopBar.tsx +27 -0
  188. package/src/views/page-editor/PageSectionEditor.tsx +58 -3
  189. package/src/views/page-editor/PageSettingsModal.tsx +1 -45
  190. package/src/views/page-editor/SectionInspector.tsx +2 -2
  191. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  192. package/src/views/post-editor/PostFieldsModal.tsx +19 -138
  193. package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
  194. package/src/views/post-editor/PostSectionEditor.tsx +60 -3
  195. package/src/views/seo/ContentTab.tsx +11 -5
  196. package/src/views/seo/LinksTab.tsx +462 -0
  197. package/src/views/seo/OverviewTab.tsx +1 -1
  198. package/src/views/seo/RedirectsTab.tsx +97 -4
  199. package/src/views/seo/TechnicalTab.tsx +2 -2
  200. package/src/views/settings/AISettingsTab.tsx +3 -3
  201. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  202. package/src/views/settings/AppearanceTab.tsx +1 -1
  203. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  204. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  205. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  206. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  207. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  208. package/src/views/settings/UpdatesTab.tsx +3 -3
  209. package/src/views/settings/components.tsx +3 -3
@@ -236,7 +236,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
236
236
  <div className="flex h-full min-h-0 flex-col">
237
237
  <div className="p-3 pr-6 pb-0 sm:p-4 sm:pr-8 sm:pb-0">
238
238
  {error && (
239
- <div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-3">
239
+ <div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-4">
240
240
  <AlertTriangle className="text-destructive h-5 w-5 shrink-0" />
241
241
  <span className="text-destructive flex-1 text-sm">{error}</span>
242
242
  <button
@@ -334,7 +334,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
334
334
  </div>
335
335
 
336
336
  <SplitPaneLayout>
337
- <SplitPaneList className="p-3 pt-0 pr-6 sm:p-4 sm:pt-0 sm:pr-8">
337
+ <SplitPaneList className="p-4 pr-8 sm:pr-8">
338
338
  {tags.length === 0 && !error ? (
339
339
  <div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)] p-12 text-center">
340
340
  <Code2 className="mx-auto mb-3 h-10 w-10 text-[var(--sub)]" />
@@ -982,7 +982,7 @@ export function Users({ embedded = false, currentPath }: UsersProps = {}) {
982
982
 
983
983
  {/* Role filter pills */}
984
984
  <div
985
- className="border-border flex flex-wrap gap-2 border-b px-4 py-3"
985
+ className="border-border flex flex-wrap gap-2 border-b px-6 py-4"
986
986
  role="group"
987
987
  aria-label="Filter by role"
988
988
  >
@@ -15,6 +15,7 @@ import {
15
15
  Tablet,
16
16
  TriangleAlert,
17
17
  } from 'lucide-react'
18
+ import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
18
19
  import type { PageStatus } from '../../lib/page-editor-service.js'
19
20
  import { VIEWPORTS, type ViewportId } from './viewports.js'
20
21
 
@@ -57,6 +58,11 @@ interface EditorTopBarProps {
57
58
  */
58
59
  onToggleSitePreview?: () => void
59
60
  onOpenSettings: () => void
61
+ /** Open the document SEO panel (meta, social, schema, analysis). */
62
+ onOpenSeo?: () => void
63
+ seoOpen?: boolean
64
+ /** Live 0–100 SEO score shown left of the SEO label. */
65
+ seoScore?: number
60
66
  onSaveDraft: () => void
61
67
  onPublish: () => void
62
68
  /**
@@ -102,6 +108,9 @@ export function EditorTopBar({
102
108
  sitePreview = false,
103
109
  onToggleSitePreview,
104
110
  onOpenSettings,
111
+ onOpenSeo,
112
+ seoOpen = false,
113
+ seoScore = 0,
105
114
  onSaveDraft,
106
115
  onPublish,
107
116
  structuralTypes = [],
@@ -192,6 +201,24 @@ export function EditorTopBar({
192
201
  <Settings2 className="h-4 w-4" aria-hidden />
193
202
  </button>
194
203
 
204
+ {onOpenSeo && (
205
+ <button
206
+ type="button"
207
+ onClick={onOpenSeo}
208
+ aria-pressed={seoOpen}
209
+ aria-label={`SEO, score ${seoScore} out of 100`}
210
+ title={`SEO — score ${seoScore}/100. Meta tags, social preview, schema, and analysis.`}
211
+ className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors ${
212
+ seoOpen
213
+ ? 'bg-primary text-primary-foreground'
214
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
215
+ }`}
216
+ >
217
+ <EditorSeoScoreBadge score={seoScore} inverted={seoOpen} />
218
+ <span className="hidden sm:inline">SEO</span>
219
+ </button>
220
+ )}
221
+
195
222
  {onHistory && (
196
223
  <button
197
224
  type="button"
@@ -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 { EditorSeoAside } from '../../components/EditorSeoAside.js'
7
8
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
8
9
  import { VersionHistory } from '../../components/VersionHistory.js'
9
10
  import {
@@ -44,6 +45,7 @@ import { AddSectionModal } from './AddSectionModal.js'
44
45
  import { PageSettingsModal } from './PageSettingsModal.js'
45
46
  import { ValidationSummary } from './ValidationSummary.js'
46
47
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
48
+ import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
47
49
 
48
50
  interface PageSectionEditorProps {
49
51
  /** undefined when creating a new page (`/pages/new`) */
@@ -97,6 +99,7 @@ export function PageSectionEditor({
97
99
 
98
100
  const [addOpen, setAddOpen] = useState(false)
99
101
  const [settingsOpen, setSettingsOpen] = useState(false)
102
+ const [seoOpen, setSeoOpen] = useState(false)
100
103
  const [historyOpen, setHistoryOpen] = useState(false)
101
104
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
102
105
  const [confirmPublish, setConfirmPublish] = useState(false)
@@ -126,6 +129,17 @@ export function PageSectionEditor({
126
129
  [page],
127
130
  )
128
131
  const publishReady = Boolean(page) && publishCheck.errors.length === 0
132
+
133
+ const liveSeoScore = useMemo(() => {
134
+ if (!page) return 0
135
+ return computeLiveSeoScore({
136
+ title: page.title,
137
+ slug: page.slug,
138
+ seoTitle: page.seoTitle,
139
+ seoDescription: page.seoDescription,
140
+ sections: page.sections,
141
+ })
142
+ }, [page])
129
143
  const publishDisabledReason = publishCheck.errors[0]
130
144
 
131
145
  // ─── Load ──────────────────────────────────────────────────────────
@@ -336,6 +350,7 @@ export function PageSectionEditor({
336
350
  .then((p) => {
337
351
  setPage(p)
338
352
  setSelectedId(null)
353
+ setSeoOpen(false)
339
354
  setDirty(false)
340
355
  setSaveState('idle')
341
356
  setValidation(null)
@@ -345,6 +360,27 @@ export function PageSectionEditor({
345
360
  })
346
361
  }, [page?.id])
347
362
 
363
+ const handleSeoSaved = useCallback(() => {
364
+ if (!page?.id) return
365
+ fetchPageForEditor(page.id)
366
+ .then((p) => setPage(p))
367
+ .catch(() => {
368
+ /* SEO API saved; editor meta will sync on next full load */
369
+ })
370
+ }, [page?.id])
371
+
372
+ const handleToggleSeo = useCallback(() => {
373
+ setSeoOpen((open) => {
374
+ if (!open) setSelectedId(null)
375
+ return !open
376
+ })
377
+ }, [])
378
+
379
+ const handleSelectSection = useCallback((id: string | null) => {
380
+ setSeoOpen(false)
381
+ setSelectedId(id)
382
+ }, [])
383
+
348
384
  // ─── Navigation with unsaved guard ───────────────────────────────────
349
385
  const guardedNavigate = useCallback(
350
386
  (path: string) => {
@@ -492,6 +528,9 @@ export function PageSectionEditor({
492
528
  onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
493
529
  onHistory={page.id ? () => setHistoryOpen(true) : undefined}
494
530
  onOpenSettings={() => setSettingsOpen(true)}
531
+ onOpenSeo={handleToggleSeo}
532
+ seoOpen={seoOpen}
533
+ seoScore={liveSeoScore}
495
534
  onSaveDraft={handleSaveDraft}
496
535
  onPublish={requestPublish}
497
536
  structuralTypes={structuralTypes}
@@ -550,7 +589,7 @@ export function PageSectionEditor({
550
589
  sections={page.sections}
551
590
  selectedId={selectedId}
552
591
  canEdit={effectiveCanEdit}
553
- onSelect={setSelectedId}
592
+ onSelect={handleSelectSection}
554
593
  onReorder={handleReorder}
555
594
  onToggleVisibility={handleToggleVisibility}
556
595
  onDuplicate={handleDuplicate}
@@ -571,7 +610,7 @@ export function PageSectionEditor({
571
610
  sections={page.sections}
572
611
  selectedId={selectedId}
573
612
  viewport={viewport}
574
- onSelect={setSelectedId}
613
+ onSelect={handleSelectSection}
575
614
  onAddSection={() => setAddOpen(true)}
576
615
  structuralTypes={structuralTypes}
577
616
  sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
@@ -579,7 +618,7 @@ export function PageSectionEditor({
579
618
  )}
580
619
  </div>
581
620
 
582
- {selected && (
621
+ {selected && !seoOpen && (
583
622
  <SectionInspector
584
623
  section={selected}
585
624
  canEdit={effectiveCanEdit}
@@ -590,6 +629,22 @@ export function PageSectionEditor({
590
629
  onToggleVisibility={() => handleToggleVisibility(selected.id)}
591
630
  />
592
631
  )}
632
+
633
+ {seoOpen && (
634
+ <EditorSeoAside
635
+ entityType="page"
636
+ entityId={page.id}
637
+ title={page.title}
638
+ slug={page.slug}
639
+ seoTitle={page.seoTitle}
640
+ seoDescription={page.seoDescription}
641
+ sections={page.sections}
642
+ canEdit={effectiveCanEdit}
643
+ onClose={() => setSeoOpen(false)}
644
+ onDraftChange={handlePageSettings}
645
+ onSaved={handleSeoSaved}
646
+ />
647
+ )}
593
648
  </div>
594
649
  </div>
595
650
 
@@ -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
  )
@@ -109,7 +109,7 @@ export function SectionInspector({
109
109
  aria-label={`${section.name} (read-only)`}
110
110
  className={INSPECTOR_SHELL}
111
111
  >
112
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
112
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
113
113
  <div className="min-w-0">
114
114
  <h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
115
115
  <p className="text-muted-foreground text-xs">Managed outside the editor</p>
@@ -142,7 +142,7 @@ export function SectionInspector({
142
142
 
143
143
  return (
144
144
  <ResizableAside role="dialog" aria-label={`Edit ${section.name}`} className={INSPECTOR_SHELL}>
145
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
145
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
146
146
  <div className="min-w-0">
147
147
  <h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
148
148
  <p className="text-muted-foreground text-xs">{def?.name ?? 'Section'}</p>
@@ -78,7 +78,7 @@ export function SectionsPanel({
78
78
  className="bg-card border-border flex h-full w-72 shrink-0 flex-col border-r"
79
79
  aria-label="Page sections"
80
80
  >
81
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
81
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
82
82
  <div className="flex items-center gap-2">
83
83
  <h2 className="text-foreground text-sm font-medium">Sections</h2>
84
84
  <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
@@ -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
 
@@ -33,7 +33,7 @@ export function PostHeaderPanel({ header, canEdit, onClose, onChange }: PostHead
33
33
  aria-label="Edit post header"
34
34
  className="bg-card border-border flex h-full flex-col border-l"
35
35
  >
36
- <div className="border-border flex items-center justify-between border-b px-4 py-3">
36
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
37
37
  <div className="min-w-0">
38
38
  <h2 className="text-foreground truncate text-sm font-medium">Post header</h2>
39
39
  <p className="text-muted-foreground text-xs">Layout for every post of this type</p>