@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
@@ -65,8 +65,10 @@ vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
65
65
  vi.mock('../../lib/seo-service.js', () => ({
66
66
  fetchSeoContentRecords: () => fetchSeoContentRecords(),
67
67
  fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
68
+ fetchDocumentSeoAnalysis: vi.fn(async () => ({ score: 72, checks: [] })),
68
69
  updateSeoFields: (t: string, i: string, p: unknown) => updateSeoFields(t, i, p),
69
70
  generateSeoField: vi.fn(async () => ({ unavailable: true })),
71
+ fetchLinkSuggestions: vi.fn(async () => ({ data: { suggestions: [] } })),
70
72
  SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
71
73
  applyContentSeoQuery: (
72
74
  records: Array<{ entityType: string; title: string }>,
@@ -1,101 +1,48 @@
1
1
  'use client'
2
2
 
3
- import { useEffect } from 'react'
4
- import { Search, X } from 'lucide-react'
5
- import { SeoCharCount } from './seo/SeoCharCount.js'
3
+ import { useEffect, useMemo } from 'react'
6
4
  import { SeoEditorPane } from './seo/SeoEditorPane.js'
7
- import { InspectorExpandButton, ResizableAside } from './ui/ResizableAside.js'
8
-
9
- const SEO_TITLE_MIN = 30
10
- const SEO_TITLE_MAX = 60
11
- const SEO_DESC_MIN = 120
12
- const SEO_DESC_MAX = 160
5
+ import { ResizableAside } from './ui/ResizableAside.js'
6
+ import { buildEditorContentHtml } from '../lib/editor-seo-content.js'
7
+ import type { PageSection } from '@actuate-media/cms-core/page-sections'
13
8
 
14
9
  const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
15
- const LABEL = 'text-foreground mb-1 block text-xs font-medium'
16
- const INPUT =
17
- 'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
18
10
 
19
11
  export interface EditorSeoAsideProps {
20
12
  entityType: 'page' | 'post'
21
- /** Saved document id — when absent, shows a draft-only meta form. */
13
+ /** Saved document id — when absent, draft mode with live analysis. */
22
14
  entityId: string | null
23
15
  title: string
16
+ slug: string
24
17
  seoTitle: string
25
18
  seoDescription: string
19
+ /** Post body HTML and/or section content for live SEO analysis. */
20
+ sections?: PageSection[]
21
+ bodyHtml?: string
26
22
  canEdit: boolean
27
23
  onClose: () => void
28
- /** Sync basic meta fields on the in-memory editor model (draft flow). */
29
24
  onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
30
- /** Refetch the editor document after SEO API save (saved flow). */
31
25
  onSaved?: () => void
32
26
  }
33
27
 
34
- function DraftSeoForm({
35
- title,
36
- seoTitle,
37
- seoDescription,
38
- canEdit,
39
- onDraftChange,
40
- }: Pick<
41
- EditorSeoAsideProps,
42
- 'title' | 'seoTitle' | 'seoDescription' | 'canEdit' | 'onDraftChange'
43
- >) {
44
- return (
45
- <div className="flex-1 space-y-4 overflow-y-auto p-4">
46
- <p className="border-border bg-muted text-muted-foreground rounded-md border p-4 text-sm">
47
- Save a draft first to unlock the full SEO editor — focus keyword, social previews, schema,
48
- robots, and AI assists. You can still set a basic title and description below; they are
49
- saved with the document.
50
- </p>
51
- <div>
52
- <div className="mb-1 flex items-center justify-between">
53
- <label className={`${LABEL} mb-0`} htmlFor="editor-seo-title">
54
- SEO title
55
- </label>
56
- <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
57
- </div>
58
- <input
59
- id="editor-seo-title"
60
- className={INPUT}
61
- value={seoTitle}
62
- disabled={!canEdit}
63
- placeholder={title || 'Defaults to the page title'}
64
- onChange={(e) => onDraftChange?.({ seoTitle: e.target.value })}
65
- />
66
- </div>
67
- <div>
68
- <div className="mb-1 flex items-center justify-between">
69
- <label className={`${LABEL} mb-0`} htmlFor="editor-seo-desc">
70
- SEO description
71
- </label>
72
- <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
73
- </div>
74
- <textarea
75
- id="editor-seo-desc"
76
- className={`${INPUT} min-h-[96px] resize-y`}
77
- value={seoDescription}
78
- disabled={!canEdit}
79
- placeholder="Summarize this content for search results (120–160 characters)"
80
- onChange={(e) => onDraftChange?.({ seoDescription: e.target.value })}
81
- />
82
- </div>
83
- </div>
84
- )
85
- }
86
-
87
28
  /** Right-side SEO panel for the Page / Post section editors. */
88
29
  export function EditorSeoAside({
89
30
  entityType,
90
31
  entityId,
91
- title,
32
+ slug,
92
33
  seoTitle,
93
34
  seoDescription,
94
- canEdit,
35
+ sections = [],
36
+ bodyHtml = '',
95
37
  onClose,
96
38
  onDraftChange,
97
39
  onSaved,
98
40
  }: EditorSeoAsideProps) {
41
+ const contentHtml = useMemo(
42
+ () => buildEditorContentHtml(sections, bodyHtml),
43
+ [sections, bodyHtml],
44
+ )
45
+
99
46
  useEffect(() => {
100
47
  function onKey(e: KeyboardEvent) {
101
48
  if (e.key === 'Escape') onClose()
@@ -106,44 +53,16 @@ export function EditorSeoAside({
106
53
 
107
54
  return (
108
55
  <ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
109
- {entityId ? (
110
- <SeoEditorPane
111
- entityType={entityType}
112
- entityId={entityId}
113
- onClose={onClose}
114
- onSaved={onSaved}
115
- />
116
- ) : (
117
- <>
118
- <div className="border-border flex items-center justify-between border-b px-6 py-4">
119
- <div className="flex min-w-0 items-center gap-2">
120
- <Search className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
121
- <div className="min-w-0">
122
- <h2 className="text-foreground truncate text-sm font-medium">SEO</h2>
123
- <p className="text-muted-foreground truncate text-xs">{title || 'Untitled'}</p>
124
- </div>
125
- </div>
126
- <div className="flex shrink-0 items-center gap-0.5">
127
- <InspectorExpandButton />
128
- <button
129
- type="button"
130
- onClick={onClose}
131
- aria-label="Close SEO panel"
132
- className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
133
- >
134
- <X className="h-4 w-4" aria-hidden />
135
- </button>
136
- </div>
137
- </div>
138
- <DraftSeoForm
139
- title={title}
140
- seoTitle={seoTitle}
141
- seoDescription={seoDescription}
142
- canEdit={canEdit}
143
- onDraftChange={onDraftChange}
144
- />
145
- </>
146
- )}
56
+ <SeoEditorPane
57
+ entityType={entityType}
58
+ entityId={entityId}
59
+ slug={slug}
60
+ contentHtml={contentHtml}
61
+ draftMeta={{ seoTitle, seoDescription }}
62
+ onDraftChange={onDraftChange}
63
+ onClose={onClose}
64
+ onSaved={onSaved}
65
+ />
147
66
  </ResizableAside>
148
67
  )
149
68
  }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import { gradeFromScore, gradeMeta } from './primitives.js'
4
+
5
+ /** Compact color-coded score for the page/post editor toolbar (`77/100` left of "SEO"). */
6
+ export function EditorSeoScoreBadge({
7
+ score,
8
+ inverted = false,
9
+ }: {
10
+ score: number
11
+ /** When the SEO toggle uses `bg-primary`, render score in primary-foreground. */
12
+ inverted?: boolean
13
+ }) {
14
+ const meta = gradeMeta(gradeFromScore(score))
15
+ return (
16
+ <span
17
+ className={`inline-flex items-baseline gap-0.5 text-xs font-medium tabular-nums ${
18
+ inverted ? 'text-primary-foreground' : meta.text
19
+ }`}
20
+ aria-hidden
21
+ >
22
+ {score}
23
+ <span className={inverted ? 'text-primary-foreground/80' : 'opacity-70'}>/100</span>
24
+ </span>
25
+ )
26
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import * as Accordion from '@radix-ui/react-accordion'
5
+ import { ChevronDown } from 'lucide-react'
6
+
7
+ export function SeoAccordion({
8
+ children,
9
+ defaultOpen = ['focus-keyword', 'search-appearance'],
10
+ }: {
11
+ children: ReactNode
12
+ defaultOpen?: string[]
13
+ }) {
14
+ return (
15
+ <Accordion.Root
16
+ type="multiple"
17
+ defaultValue={defaultOpen}
18
+ className="divide-y divide-[var(--bdr)]"
19
+ >
20
+ {children}
21
+ </Accordion.Root>
22
+ )
23
+ }
24
+
25
+ export function SeoAccordionItem({
26
+ value,
27
+ title,
28
+ badge,
29
+ children,
30
+ }: {
31
+ value: string
32
+ title: string
33
+ badge?: ReactNode
34
+ children: ReactNode
35
+ }) {
36
+ return (
37
+ <Accordion.Item value={value} className="border-[var(--bdr)] bg-[var(--sb)]">
38
+ <Accordion.Header>
39
+ <Accordion.Trigger className="group flex w-full items-center justify-between gap-2 px-5 py-3.5 text-left transition-colors duration-[var(--motion-fast)] hover:bg-[var(--bg)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none">
40
+ <span className="flex min-w-0 items-center gap-2">
41
+ <span className="text-[13px] font-medium text-[var(--txt)]">{title}</span>
42
+ {badge}
43
+ </span>
44
+ <ChevronDown
45
+ size={16}
46
+ aria-hidden
47
+ className="shrink-0 text-[var(--muted)] group-data-[state=open]:rotate-180 motion-safe:transition-transform motion-safe:duration-200"
48
+ />
49
+ </Accordion.Trigger>
50
+ </Accordion.Header>
51
+ <Accordion.Content className="overflow-hidden data-[state=closed]:hidden">
52
+ <div className="space-y-3 px-5 pt-1 pb-4">{children}</div>
53
+ </Accordion.Content>
54
+ </Accordion.Item>
55
+ )
56
+ }
57
+
58
+ export function SeoAccordionBadge({
59
+ tone,
60
+ children,
61
+ }: {
62
+ tone: 'neutral' | 'good' | 'warn' | 'bad'
63
+ children: ReactNode
64
+ }) {
65
+ const cls =
66
+ tone === 'good'
67
+ ? 'bg-[var(--suc-l)] text-[var(--suc)]'
68
+ : tone === 'warn'
69
+ ? 'bg-[var(--warn-l)] text-[var(--warn)]'
70
+ : tone === 'bad'
71
+ ? 'bg-[var(--err-l)] text-[var(--err)]'
72
+ : 'bg-[var(--bg)] text-[var(--sub)]'
73
+ return (
74
+ <span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cls}`}>{children}</span>
75
+ )
76
+ }
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { AlertCircle, CheckCircle2, Sparkles, Wand2 } from 'lucide-react'
4
+ import type { SEOCheck } from '@actuate-media/cms-core/seo/analysis'
5
+
6
+ export function SeoAnalysisList({
7
+ checks,
8
+ onAiFixField,
9
+ aiFixField,
10
+ }: {
11
+ checks: SEOCheck[]
12
+ onAiFixField?: (field: 'metaTitle' | 'metaDescription' | 'focusKeyword') => void
13
+ aiFixField?: string | null
14
+ }) {
15
+ const problems = checks.filter((c) => c.status === 'problem' || c.status === 'improvement')
16
+ const good = checks.filter((c) => c.status === 'good')
17
+
18
+ if (checks.length === 0) {
19
+ return (
20
+ <p className="text-[12px] text-[var(--muted)]">
21
+ Add content and a focus keyphrase to run SEO checks.
22
+ </p>
23
+ )
24
+ }
25
+
26
+ return (
27
+ <div className="space-y-4">
28
+ {problems.length > 0 && (
29
+ <div>
30
+ <h4 className="mb-2 text-[11px] font-semibold tracking-wide text-[var(--sub)] uppercase">
31
+ Problems ({problems.length})
32
+ </h4>
33
+ <ul className="space-y-2">
34
+ {problems.map((check) => (
35
+ <AnalysisRow
36
+ key={check.id}
37
+ check={check}
38
+ onAiFix={onAiFixField}
39
+ aiBusy={aiFixField}
40
+ />
41
+ ))}
42
+ </ul>
43
+ </div>
44
+ )}
45
+ {good.length > 0 && (
46
+ <div>
47
+ <h4 className="mb-2 text-[11px] font-semibold tracking-wide text-[var(--sub)] uppercase">
48
+ Good results ({good.length})
49
+ </h4>
50
+ <ul className="space-y-2">
51
+ {good.map((check) => (
52
+ <AnalysisRow key={check.id} check={check} />
53
+ ))}
54
+ </ul>
55
+ </div>
56
+ )}
57
+ </div>
58
+ )
59
+ }
60
+
61
+ function AnalysisRow({
62
+ check,
63
+ onAiFix,
64
+ aiBusy,
65
+ }: {
66
+ check: SEOCheck
67
+ onAiFix?: (field: 'metaTitle' | 'metaDescription' | 'focusKeyword') => void
68
+ aiBusy?: string | null
69
+ }) {
70
+ const tone =
71
+ check.status === 'good'
72
+ ? 'text-[var(--suc)]'
73
+ : check.status === 'improvement'
74
+ ? 'text-[var(--warn)]'
75
+ : 'text-[var(--err)]'
76
+ const Icon = check.status === 'good' ? CheckCircle2 : AlertCircle
77
+ const aiField = aiFieldForCheck(check.id)
78
+
79
+ return (
80
+ <li className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-2">
81
+ <Icon className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${tone}`} aria-hidden />
82
+ <div className="min-w-0 flex-1">
83
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{check.title}</div>
84
+ <p className="text-[11.5px] text-[var(--sub)]">{check.description}</p>
85
+ </div>
86
+ {onAiFix && aiField && check.status !== 'good' && (
87
+ <button
88
+ type="button"
89
+ aria-label={`AI help for ${check.title}`}
90
+ disabled={Boolean(aiBusy)}
91
+ onClick={() => onAiFix(aiField)}
92
+ className="inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
93
+ >
94
+ {aiBusy === aiField ? (
95
+ <Sparkles className="h-3 w-3 animate-pulse" aria-hidden />
96
+ ) : (
97
+ <Wand2 className="h-3 w-3" aria-hidden />
98
+ )}
99
+ AI
100
+ </button>
101
+ )}
102
+ </li>
103
+ )
104
+ }
105
+
106
+ function aiFieldForCheck(checkId: string): 'metaTitle' | 'metaDescription' | 'focusKeyword' | null {
107
+ if (checkId.includes('meta-title') || checkId.includes('keyphrase-in-title')) return 'metaTitle'
108
+ if (checkId.includes('meta-description') || checkId.includes('keyphrase-in-meta-description')) {
109
+ return 'metaDescription'
110
+ }
111
+ if (checkId.includes('keyphrase')) return 'focusKeyword'
112
+ return null
113
+ }