@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
@@ -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
+ }
@@ -321,7 +321,11 @@ export function SeoIssueFixPanel({
321
321
  toast.error(res.error)
322
322
  return
323
323
  }
324
- toast.success('Fix applied and issue resolved.')
324
+ toast.success(
325
+ suggestion?.fixStrategy === 'fix-link'
326
+ ? 'Link updated in content and issue resolved.'
327
+ : 'Redirect created and issue resolved.',
328
+ )
325
329
  onChanged?.()
326
330
  }
327
331
 
@@ -408,11 +412,15 @@ export function SeoIssueFixPanel({
408
412
  {suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
409
413
  <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
410
414
  <Sparkles className="h-3 w-3" aria-hidden />
411
- AI suggestion
415
+ {suggestion.fixStrategy === 'fix-link'
416
+ ? 'Fix link in content'
417
+ : 'AI suggestion'}
412
418
  </span>
413
419
  ) : suggestion.source === 'deterministic' ? (
414
420
  <span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
415
- Rule-based fix
421
+ {suggestion.fixStrategy === 'fix-link'
422
+ ? 'Content link fix'
423
+ : 'Rule-based fix'}
416
424
  </span>
417
425
  ) : null}
418
426
  </div>
@@ -483,7 +491,11 @@ export function SeoIssueFixPanel({
483
491
  disabled={busy}
484
492
  >
485
493
  <Check className="h-4 w-4" aria-hidden />
486
- {busy ? 'Applying…' : 'Approve fix'}
494
+ {busy
495
+ ? 'Applying…'
496
+ : suggestion.fixStrategy === 'fix-link'
497
+ ? 'Fix link in content'
498
+ : 'Approve redirect'}
487
499
  </button>
488
500
  ) : inlineFix && suggestion?.autoFixable && !inlineApplyEnabled ? (
489
501
  <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
@@ -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
+ }
@@ -153,14 +153,18 @@ export function SectionCard({
153
153
  {action}
154
154
  </CardHeader>
155
155
  )}
156
- <CardContent className={title || action ? 'pt-0' : ''}>{children}</CardContent>
156
+ <CardContent className={title || action ? '' : 'pt-6!'}>{children}</CardContent>
157
157
  </Card>
158
158
  )
159
159
  }
160
160
 
161
161
  export function SeoLoading({ label = 'Loading…' }: { label?: string }) {
162
162
  return (
163
- <div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
163
+ <div
164
+ className="flex min-h-32 items-center justify-center py-12"
165
+ role="status"
166
+ aria-live="polite"
167
+ >
164
168
  <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
165
169
  <span className="sr-only">{label}</span>
166
170
  </div>
@@ -195,7 +199,7 @@ export function SeoEmptyState({
195
199
  export function SeoErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
196
200
  return (
197
201
  <div
198
- className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
202
+ className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-4"
199
203
  role="alert"
200
204
  >
201
205
  <AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
@@ -10,8 +10,8 @@ const card = cv(
10
10
  /** Stat / compact surfaces (`.dstat`). */
11
11
  sm: 'px-4 py-3.5',
12
12
  /** Simple padded panel (`.dcard-b`). */
13
- md: 'px-[18px] py-4',
14
- lg: 'px-[18px] py-6',
13
+ md: 'px-6 py-4',
14
+ lg: 'px-6 py-6',
15
15
  },
16
16
  elevation: {
17
17
  flat: 'shadow-none',
@@ -55,7 +55,7 @@ export function CardHeader({
55
55
  }: HTMLAttributes<HTMLDivElement> & { bordered?: boolean }) {
56
56
  return (
57
57
  <div
58
- className={[bordered ? 'border-b border-[var(--bdr)] px-[18px] py-[14px]' : '', className]
58
+ className={[bordered ? 'border-b border-[var(--bdr)] px-6 pt-6 pb-4' : '', className]
59
59
  .filter(Boolean)
60
60
  .join(' ')}
61
61
  {...rest}
@@ -96,7 +96,7 @@ export function CardDescription({
96
96
  export function CardContent({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
97
97
  return (
98
98
  <div
99
- className={`px-[18px] py-4 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
99
+ className={`px-6 pt-4 pb-6 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
100
100
  {...rest}
101
101
  >
102
102
  {children}
@@ -107,7 +107,7 @@ export function CardContent({ className = '', children, ...rest }: HTMLAttribute
107
107
  export function CardFooter({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
108
108
  return (
109
109
  <div
110
- className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-[18px] py-3 ${className}`}
110
+ className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-6 py-4 ${className}`}
111
111
  {...rest}
112
112
  >
113
113
  {children}
@@ -86,7 +86,7 @@ export function InspectorPaneHeader({
86
86
  closeLabel?: string
87
87
  }) {
88
88
  return (
89
- <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-4 py-3.5">
89
+ <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-5 py-4">
90
90
  <div className="min-w-0 flex-1">{children}</div>
91
91
  <button
92
92
  type="button"
@@ -115,7 +115,7 @@ export function InspectorPaneSection({
115
115
  children: ReactNode
116
116
  }) {
117
117
  return (
118
- <div className="border-b border-[var(--bdr)] px-4 py-3.5 last:border-b-0">
118
+ <div className="border-b border-[var(--bdr)] px-5 py-4 last:border-b-0">
119
119
  {title && (
120
120
  <div className="mb-2.5 flex items-baseline gap-2">
121
121
  <span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
@@ -130,5 +130,5 @@ export function InspectorPaneSection({
130
130
  }
131
131
 
132
132
  export function InspectorPaneFooter({ children }: { children: ReactNode }) {
133
- return <div className="border-t border-[var(--bdr)] px-4 py-3.5">{children}</div>
133
+ return <div className="border-t border-[var(--bdr)] px-5 py-4">{children}</div>
134
134
  }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ /**
6
+ * Bordered panel with a header divider — Dashboard, modals, and list-heavy views.
7
+ * Matches Settings/Section card rhythm: `px-6` gutters, `py-4` header, body flush
8
+ * below the divider with its own padding.
9
+ */
10
+ export function PanelSection({
11
+ title,
12
+ description,
13
+ action,
14
+ children,
15
+ className = '',
16
+ }: {
17
+ title?: ReactNode
18
+ description?: ReactNode
19
+ action?: ReactNode
20
+ children: ReactNode
21
+ className?: string
22
+ }) {
23
+ return (
24
+ <section
25
+ className={`bg-card border-border overflow-hidden rounded-xl border shadow-sm ${className}`}
26
+ >
27
+ {(title || action) && (
28
+ <header className="border-border flex items-center justify-between gap-3 border-b px-6 py-4">
29
+ <div className="min-w-0">
30
+ {title &&
31
+ (typeof title === 'string' ? (
32
+ <h2 className="text-foreground text-sm font-medium">{title}</h2>
33
+ ) : (
34
+ title
35
+ ))}
36
+ {description &&
37
+ (typeof description === 'string' ? (
38
+ <p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
39
+ ) : (
40
+ description
41
+ ))}
42
+ </div>
43
+ {action}
44
+ </header>
45
+ )}
46
+ {children}
47
+ </section>
48
+ )
49
+ }
50
+
51
+ /** Standard inset for list rows / table bodies inside a {@link PanelSection}. */
52
+ export const PANEL_LIST_ROW_CLASS =
53
+ 'hover:bg-accent/50 focus-visible:bg-accent/50 w-full px-6 py-4 text-left transition-colors'
@@ -0,0 +1,15 @@
1
+ /** Marks the canonical admin shell root (AdminRoot / LoginRoot). */
2
+ export const ADMIN_ROOT_ATTR = 'data-actuate-admin-root'
3
+
4
+ /**
5
+ * Resolve the element that owns admin theme tokens (accent, color mode, density).
6
+ * Prefer the marked shell root so a consumer wrapper `.actuate-admin` does not
7
+ * steal runtime overrides from the inner AdminRoot.
8
+ */
9
+ export function getAdminRootElement(): HTMLElement | null {
10
+ if (typeof document === 'undefined') return null
11
+ return (
12
+ document.querySelector<HTMLElement>(`[${ADMIN_ROOT_ATTR}]`) ??
13
+ document.querySelector<HTMLElement>('.actuate-admin')
14
+ )
15
+ }
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { getAdminRootElement } from './admin-root-element.js'
2
+
1
3
  /**
2
4
  * Admin theme tokens are scoped under `.actuate-admin` (styles/theme.css),
3
5
  * and dark mode / accent / density are applied as classes-attributes on that
@@ -9,6 +11,5 @@
9
11
  * Returns `undefined` during SSR (portals only materialize client-side).
10
12
  */
11
13
  export function adminPortalContainer(): HTMLElement | undefined {
12
- if (typeof document === 'undefined') return undefined
13
- return document.querySelector<HTMLElement>('.actuate-admin') ?? undefined
14
+ return getAdminRootElement() ?? undefined
14
15
  }