@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
@@ -1,20 +1,35 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * Inline SEO editor pane design-parity replacement for the old
5
- * `SeoEditorDrawer` overlay. Rendered inside an `<InspectorPane>` beside the
6
- * Content SEO list (380px in the handoff). Same fields, validation, AI
7
- * assists, and `updateSeoFields` save path as the drawer; only the
8
- * presentation pattern changed.
4
+ * Inline SEO editor pane for the post/page section editors and the Content SEO
5
+ * list. Accordion sections mirror Yoast-style workflow: Focus Keyword → Search
6
+ * Appearance Social Analysis Advanced Insights Linking.
9
7
  */
10
- import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
11
- import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
8
+ import {
9
+ cloneElement,
10
+ isValidElement,
11
+ useEffect,
12
+ useId,
13
+ useMemo,
14
+ useState,
15
+ type ReactElement,
16
+ type ReactNode,
17
+ } from 'react'
18
+ import { Loader2, Monitor, Smartphone, Sparkles, Wand2 } from 'lucide-react'
12
19
  import { toast } from 'sonner'
13
20
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
21
+ import { resolveRenderedMetaTitle } from '@actuate-media/cms-core/client'
22
+ import {
23
+ analyzeContent,
24
+ calculateReadability,
25
+ stripHtmlTags,
26
+ type SEOAnalysisResult,
27
+ } from '@actuate-media/cms-core/seo/analysis'
14
28
  import {
15
29
  fetchSeoContentRecord,
16
30
  updateSeoFields,
17
31
  generateSeoField,
32
+ fetchDocumentSeoAnalysis,
18
33
  SEO_SCHEMA_TYPES,
19
34
  type SeoContentRecord,
20
35
  type SeoFieldPayload,
@@ -22,14 +37,13 @@ import {
22
37
  } from '../../lib/seo-service.js'
23
38
  import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
24
39
  import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
25
- import {
26
- InspectorPaneBody,
27
- InspectorPaneFooter,
28
- InspectorPaneHeader,
29
- InspectorPaneSection,
30
- } from '../ui/InspectorPane.js'
40
+ import { InspectorPaneBody, InspectorPaneFooter, InspectorPaneHeader } from '../ui/InspectorPane.js'
31
41
  import { Toggle } from '../ui/Toggle.js'
32
42
  import { SeoLoading, SeoErrorState, gradeMeta } from './primitives.js'
43
+ import { SeoAccordion, SeoAccordionBadge, SeoAccordionItem } from './SeoAccordion.js'
44
+ import { SeoAnalysisList } from './SeoAnalysisList.js'
45
+ import { SeoInsightsMetrics } from './SeoInsightsMetrics.js'
46
+ import { SeoLinkingSection } from './SeoLinkingSection.js'
33
47
 
34
48
  const META_TITLE_MAX = 60
35
49
  const META_DESC_MAX = 160
@@ -65,14 +79,13 @@ function PaneField({
65
79
  label,
66
80
  children,
67
81
  hint,
82
+ help,
68
83
  }: {
69
84
  label: string
70
- children: React.ReactNode
71
- hint?: React.ReactNode
85
+ children: ReactNode
86
+ hint?: ReactNode
87
+ help?: string
72
88
  }) {
73
- // Associate the visible label with its control for screen readers. We inject
74
- // a generated id onto the single child input/textarea (unless it already has
75
- // one) and point the <label htmlFor> at it.
76
89
  const generatedId = useId()
77
90
  let control = children
78
91
  if (isValidElement(children) && (children.props as { id?: string }).id == null) {
@@ -89,6 +102,7 @@ function PaneField({
89
102
  {hint}
90
103
  </div>
91
104
  {control}
105
+ {help && <p className="mt-1 text-[11px] text-[var(--muted)]">{help}</p>}
92
106
  </div>
93
107
  )
94
108
  }
@@ -116,16 +130,31 @@ function PaneToggleRow({
116
130
  }
117
131
 
118
132
  export interface SeoEditorPaneProps {
119
- entityType: string
120
- entityId: string
133
+ entityType: 'page' | 'post'
134
+ /** When absent, draft-only mode — meta fields sync via `onDraftChange`. */
135
+ entityId: string | null
121
136
  onClose: () => void
122
- /** Called after a successful save so the list can refetch. */
123
137
  onSaved?: () => void
138
+ /** Live HTML from the section editor for real-time analysis. */
139
+ contentHtml?: string
140
+ slug?: string
141
+ draftMeta?: { seoTitle: string; seoDescription: string }
142
+ onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
124
143
  }
125
144
 
126
- export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEditorPaneProps) {
145
+ export function SeoEditorPane({
146
+ entityType,
147
+ entityId,
148
+ onClose,
149
+ onSaved,
150
+ contentHtml = '',
151
+ slug = '',
152
+ draftMeta,
153
+ onDraftChange,
154
+ }: SeoEditorPaneProps) {
155
+ const isDraft = !entityId
127
156
  const [record, setRecord] = useState<SeoContentRecord | null>(null)
128
- const [loading, setLoading] = useState(true)
157
+ const [loading, setLoading] = useState(!isDraft)
129
158
  const [error, setError] = useState<string | null>(null)
130
159
  const [saving, setSaving] = useState(false)
131
160
  const [aiBusy, setAiBusy] = useState<AiField | null>(null)
@@ -134,8 +163,34 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
134
163
  Partial<Record<AiField, BrandAlignmentScoreResult>>
135
164
  >({})
136
165
  const [form, setForm] = useState<SeoFieldPayload>({})
166
+ const [previewDevice, setPreviewDevice] = useState<'mobile' | 'desktop'>('mobile')
167
+ const [socialPlatform, setSocialPlatform] = useState<'facebook' | 'twitter'>('facebook')
168
+ const [remoteAnalysis, setRemoteAnalysis] = useState<SEOAnalysisResult | null>(null)
137
169
 
138
170
  useEffect(() => {
171
+ if (isDraft) {
172
+ setLoading(false)
173
+ setRecord(null)
174
+ setError(null)
175
+ setForm({
176
+ metaTitle: draftMeta?.seoTitle ?? '',
177
+ metaDescription: draftMeta?.seoDescription ?? '',
178
+ focusKeyword: '',
179
+ keyTakeaway: '',
180
+ ogTitle: '',
181
+ ogDescription: '',
182
+ canonicalUrl: '',
183
+ structuredDataType: 'WebPage',
184
+ noindex: false,
185
+ nofollow: false,
186
+ llmsInclude: 'AUTO',
187
+ llmsDescription: '',
188
+ llmsSection: '',
189
+ llmsOptional: false,
190
+ })
191
+ return
192
+ }
193
+
139
194
  let alive = true
140
195
  setLoading(true)
141
196
  setError(null)
@@ -148,13 +203,15 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
148
203
  }
149
204
  setRecord(rec)
150
205
  setForm({
151
- metaTitle: rec.metaTitle ?? '',
206
+ metaTitle: rec.metaTitlePage ?? '',
152
207
  metaDescription: rec.metaDescription ?? '',
153
208
  canonicalUrl: rec.canonicalUrl ?? '',
154
209
  focusKeyword: rec.focusKeyword ?? '',
155
210
  keyTakeaway: rec.keyTakeaway ?? '',
156
211
  ogTitle: rec.ogTitle ?? '',
157
212
  ogDescription: rec.ogDescription ?? '',
213
+ twitterTitle: rec.twitterTitle ?? '',
214
+ twitterDescription: rec.twitterDescription ?? '',
158
215
  structuredDataType: rec.structuredDataType ?? 'WebPage',
159
216
  noindex: rec.noindex,
160
217
  nofollow: rec.nofollow,
@@ -169,24 +226,63 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
169
226
  return () => {
170
227
  alive = false
171
228
  }
172
- }, [entityType, entityId])
229
+ }, [entityType, entityId, isDraft])
173
230
 
174
- const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
231
+ useEffect(() => {
232
+ if (isDraft || contentHtml.trim() || !entityId) return
233
+ let alive = true
234
+ fetchDocumentSeoAnalysis(entityId)
235
+ .then((result) => alive && setRemoteAnalysis(result))
236
+ .catch(() => alive && setRemoteAnalysis(null))
237
+ return () => {
238
+ alive = false
239
+ }
240
+ }, [entityId, isDraft, contentHtml])
241
+
242
+ const update = (patch: Partial<SeoFieldPayload>) => {
243
+ setForm((f) => {
244
+ const next = { ...f, ...patch }
245
+ if (isDraft && onDraftChange) {
246
+ if ('metaTitle' in patch) onDraftChange({ seoTitle: patch.metaTitle })
247
+ if ('metaDescription' in patch) onDraftChange({ seoDescription: patch.metaDescription })
248
+ }
249
+ return next
250
+ })
251
+ }
175
252
 
176
253
  async function handleAiGenerate(field: AiField) {
254
+ if (isDraft && !contentHtml.trim() && field !== 'focusKeyword') {
255
+ toast.error('Add a title or body content before generating with AI.')
256
+ return
257
+ }
258
+
177
259
  setAiBusy(field)
178
260
  try {
179
- const result = await generateSeoField(field, { entityType, entityId })
261
+ if (isDraft) {
262
+ toast.error('Save a draft first to use AI field generation with site knowledge.')
263
+ return
264
+ }
265
+
266
+ const result = await generateSeoField(field, { entityType, entityId: entityId! })
180
267
  if (result.unavailable) {
181
268
  toast.error('AI is not configured. Add an AI provider to enable suggestions.')
182
269
  return
183
270
  }
184
271
  if (result.text) {
185
- update({ [field]: result.text } as Partial<SeoFieldPayload>)
272
+ const targetField =
273
+ field === 'ogTitle' ? 'metaTitle' : field === 'ogDescription' ? 'metaDescription' : field
274
+ const patch: Partial<SeoFieldPayload> = { [targetField]: result.text }
275
+ if (field === 'ogTitle') patch.ogTitle = ''
276
+ if (field === 'ogDescription') patch.ogDescription = ''
277
+ update(patch)
186
278
  if (result.brandAlignment) {
187
- setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
279
+ setAiAlignments((prev) => ({ ...prev, [targetField]: result.brandAlignment }))
188
280
  }
189
- toast.success('AI suggestion applied — review before saving.')
281
+ toast.success(
282
+ field === 'ogTitle' || field === 'ogDescription'
283
+ ? 'AI suggestion applied to meta tags — social fields inherit unless overridden.'
284
+ : 'AI suggestion applied — review before saving.',
285
+ )
190
286
  } else {
191
287
  toast.error('No suggestion was returned.')
192
288
  }
@@ -245,8 +341,13 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
245
341
  }
246
342
 
247
343
  async function handleSave() {
344
+ if (isDraft) {
345
+ toast.success('SEO fields will be saved with the document.')
346
+ onClose()
347
+ return
348
+ }
248
349
  setSaving(true)
249
- const res = await updateSeoFields(entityType, entityId, form)
350
+ const res = await updateSeoFields(entityType, entityId!, form)
250
351
  setSaving(false)
251
352
  if (res.error) {
252
353
  toast.error(res.error)
@@ -256,16 +357,65 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
256
357
  onSaved?.()
257
358
  }
258
359
 
259
- const title = form.metaTitle || record?.title || 'Untitled'
260
- const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
261
- const url = record?.url ?? ''
360
+ const renderedTitle = useMemo(() => {
361
+ if (isDraft) return form.metaTitle?.trim() || draftMeta?.seoTitle || 'Untitled'
362
+ if (!record) return form.metaTitle?.trim() || 'Untitled'
363
+ return resolveRenderedMetaTitle({
364
+ metaTitlePage: form.metaTitle,
365
+ documentTitle: record.title,
366
+ site: {
367
+ siteName: record.siteName ?? undefined,
368
+ titleTemplate: record.titleTemplate ?? undefined,
369
+ },
370
+ })
371
+ }, [form.metaTitle, record, isDraft, draftMeta?.seoTitle])
372
+
373
+ const desc =
374
+ form.metaDescription ||
375
+ 'Please provide a meta description. Search engines may generate one from your content.'
376
+ const url = record?.url ?? (slug ? `/${slug.replace(/^\//, '')}` : '')
377
+ const displayUrl = record?.url ?? url
378
+
379
+ const analysisInput = useMemo(
380
+ () => ({
381
+ title: record?.title ?? draftMeta?.seoTitle ?? '',
382
+ slug: slug || record?.url?.split('/').filter(Boolean).pop() || '',
383
+ content: contentHtml,
384
+ metaTitle: form.metaTitle,
385
+ metaDescription: form.metaDescription,
386
+ focusKeyphrase: form.focusKeyword,
387
+ canonical: form.canonicalUrl,
388
+ ogTitle: form.ogTitle,
389
+ ogDescription: form.ogDescription,
390
+ ogImage: record?.ogImage ?? undefined,
391
+ }),
392
+ [record, draftMeta, slug, contentHtml, form],
393
+ )
394
+
395
+ const liveAnalysis = useMemo(() => {
396
+ if (contentHtml.trim()) return analyzeContent(analysisInput)
397
+ return null
398
+ }, [analysisInput, contentHtml])
399
+
400
+ const analysis = liveAnalysis ?? remoteAnalysis
401
+ const plainText = stripHtmlTags(contentHtml)
402
+ const readability = useMemo(() => calculateReadability(plainText), [plainText])
403
+
404
+ const problemCount = analysis?.checks.filter((c) => c.status === 'problem').length ?? 0
405
+ const internalLinks =
406
+ analysis?.checks.find((c) => c.id === 'internal-links')?.description.match(/\d+/)?.[0] ?? '0'
407
+ const externalLinks =
408
+ analysis?.checks.find((c) => c.id === 'outbound-links')?.description.match(/\d+/)?.[0] ?? '0'
409
+ const internalOk = Number(internalLinks) >= 1
410
+ const externalOk = Number(externalLinks) >= 1
262
411
 
263
- function aiButton(field: AiField) {
412
+ function aiButton(field: AiField, label?: string) {
264
413
  return (
265
414
  <button
266
415
  type="button"
267
416
  onClick={() => void handleAiGenerate(field)}
268
- disabled={aiBusy !== null}
417
+ disabled={aiBusy !== null || isDraft}
418
+ title={isDraft ? 'Save a draft first' : label}
269
419
  className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
270
420
  >
271
421
  <Sparkles className={`h-3 w-3 ${aiBusy === field ? 'animate-pulse' : ''}`} aria-hidden />
@@ -274,248 +424,380 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
274
424
  )
275
425
  }
276
426
 
427
+ const socialTitle =
428
+ socialPlatform === 'twitter'
429
+ ? form.twitterTitle || form.ogTitle || renderedTitle
430
+ : form.ogTitle || renderedTitle
431
+ const socialDesc =
432
+ socialPlatform === 'twitter'
433
+ ? form.twitterDescription || form.ogDescription || form.metaDescription || desc
434
+ : form.ogDescription || form.metaDescription || desc
435
+
277
436
  return (
278
437
  <>
279
438
  <InspectorPaneHeader onClose={onClose} closeLabel="Close SEO editor">
280
439
  <div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
281
- {record?.title ?? 'Edit SEO'}
440
+ {record?.title ?? draftMeta?.seoTitle ?? 'SEO'}
282
441
  </div>
283
- {url && <div className="mt-0.5 truncate text-[11.5px] text-[var(--muted)]">{url}</div>}
442
+ {displayUrl && (
443
+ <div className="mt-0.5 truncate text-[11.5px] text-[var(--muted)]">{displayUrl}</div>
444
+ )}
284
445
  </InspectorPaneHeader>
285
446
 
286
447
  <InspectorPaneBody>
287
448
  {loading ? (
288
- <div className="px-4 py-3.5">
449
+ <div className="px-5 py-4">
289
450
  <SeoLoading />
290
451
  </div>
291
452
  ) : error ? (
292
- <div className="px-4 py-3.5">
453
+ <div className="px-5 py-4">
293
454
  <SeoErrorState message={error} />
294
455
  </div>
295
- ) : record ? (
456
+ ) : (
296
457
  <>
297
- <InspectorPaneSection title="Google Preview">
298
- <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
299
- <p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
300
- <p className="text-[15px] leading-snug text-[var(--acc)]">{title}</p>
301
- <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
458
+ {isDraft && (
459
+ <div className="border-b border-[var(--bdr)] px-5 py-3">
460
+ <p className="text-[11.5px] text-[var(--sub)]">
461
+ Save a draft to unlock AI assists trained on your site knowledge and tone. You can
462
+ still set focus keyword, search appearance, and review live analysis below.
463
+ </p>
302
464
  </div>
303
- </InspectorPaneSection>
304
-
305
- <InspectorPaneSection title="Meta Tags">
306
- <PaneField
307
- label="Meta title"
308
- hint={
309
- <span className="flex items-center gap-2">
310
- <CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
311
- {aiButton('metaTitle')}
312
- </span>
313
- }
314
- >
315
- <input
316
- className={inputCls}
317
- value={form.metaTitle ?? ''}
318
- onChange={(e) => update({ metaTitle: e.target.value })}
319
- placeholder="Page title for search results"
320
- />
321
- </PaneField>
322
- <PaneField
323
- label="Meta description"
324
- hint={
325
- <span className="flex items-center gap-2">
326
- <CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
327
- {aiButton('metaDescription')}
328
- </span>
329
- }
330
- >
331
- <textarea
332
- className={`${inputCls} min-h-20 resize-y`}
333
- value={form.metaDescription ?? ''}
334
- onChange={(e) => update({ metaDescription: e.target.value })}
335
- placeholder="Short summary shown under the title in search results"
336
- />
337
- </PaneField>
338
- {alignmentRow('metaDescription')}
339
- </InspectorPaneSection>
340
-
341
- <InspectorPaneSection title="Focus Keyword">
342
- <PaneField label="Target keyword" hint={aiButton('focusKeyword')}>
343
- <input
344
- className={inputCls}
345
- value={form.focusKeyword ?? ''}
346
- onChange={(e) => update({ focusKeyword: e.target.value })}
347
- placeholder="Primary keyword this page targets"
348
- />
349
- </PaneField>
350
- <PaneField
351
- label="Key takeaway"
352
- hint={
353
- <span className="flex items-center gap-2">
354
- <CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
355
- {aiButton('keyTakeaway')}
356
- </span>
465
+ )}
466
+
467
+ {!isDraft && record && (
468
+ <div className="border-b border-[var(--bdr)] px-5 py-3">
469
+ {(() => {
470
+ const meta = gradeMeta(record.seoGrade)
471
+ return (
472
+ <div
473
+ className={`flex items-center gap-2 rounded-[7px] px-3 py-2 text-[12px] ${meta.bg} ${meta.text}`}
474
+ >
475
+ <span>
476
+ SEO score: <span className="font-medium">{record.seoScore}</span> (
477
+ {meta.label}){analysis && ` · Live analysis ${analysis.score}`}
478
+ </span>
479
+ </div>
480
+ )
481
+ })()}
482
+ </div>
483
+ )}
484
+
485
+ <SeoAccordion>
486
+ <SeoAccordionItem
487
+ value="focus-keyword"
488
+ title="Focus keyword"
489
+ badge={
490
+ form.focusKeyword ? (
491
+ <SeoAccordionBadge tone="neutral">{form.focusKeyword}</SeoAccordionBadge>
492
+ ) : (
493
+ <SeoAccordionBadge tone="warn">Not set</SeoAccordionBadge>
494
+ )
357
495
  }
358
496
  >
359
- <textarea
360
- className={`${inputCls} min-h-16 resize-y`}
361
- value={form.keyTakeaway ?? ''}
362
- onChange={(e) => update({ keyTakeaway: e.target.value })}
363
- placeholder="One or two sentences AI answer engines can quote — what is this page about?"
364
- />
365
- </PaneField>
366
- </InspectorPaneSection>
367
-
368
- <InspectorPaneSection title="Open Graph" subtitle="Social sharing">
369
- <PaneField label="OG title" hint={aiButton('ogTitle')}>
370
- <input
371
- className={inputCls}
372
- value={form.ogTitle ?? ''}
373
- onChange={(e) => update({ ogTitle: e.target.value })}
374
- placeholder={form.metaTitle || 'Title when shared on social media'}
375
- />
376
- </PaneField>
377
- <PaneField label="OG description" hint={aiButton('ogDescription')}>
378
- <textarea
379
- className={`${inputCls} min-h-16 resize-y`}
380
- value={form.ogDescription ?? ''}
381
- onChange={(e) => update({ ogDescription: e.target.value })}
382
- placeholder={form.metaDescription || 'Description when shared on social media'}
383
- />
384
- </PaneField>
385
- {alignmentRow('ogDescription')}
386
- <div className="mt-3 overflow-hidden rounded-[7px] border border-[var(--bdr)]">
387
- {record.ogImage ? (
388
- // eslint-disable-next-line @next/next/no-img-element
389
- <img
390
- src={record.ogImage}
391
- alt={`Social preview for ${record.title}`}
392
- className="aspect-[1200/630] w-full bg-[var(--bg)] object-cover"
497
+ <PaneField
498
+ label="Focus keyphrase"
499
+ hint={aiButton('focusKeyword', 'Suggest focus keyphrase')}
500
+ help="The main phrase you want this content found for in search and AI answers."
501
+ >
502
+ <input
503
+ className={inputCls}
504
+ value={form.focusKeyword ?? ''}
505
+ onChange={(e) => update({ focusKeyword: e.target.value })}
506
+ placeholder="e.g. modern headless cms"
393
507
  />
394
- ) : (
395
- <div className="flex aspect-[1200/630] w-full items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--muted)]">
396
- No OG image set
508
+ </PaneField>
509
+ <PaneField
510
+ label="Key takeaway"
511
+ hint={
512
+ <span className="flex items-center gap-2">
513
+ <CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
514
+ {aiButton('keyTakeaway')}
515
+ </span>
516
+ }
517
+ help="One or two sentences AI answer engines can quote — what is this page about?"
518
+ >
519
+ <textarea
520
+ className={`${inputCls} min-h-16 resize-y`}
521
+ value={form.keyTakeaway ?? ''}
522
+ onChange={(e) => update({ keyTakeaway: e.target.value })}
523
+ placeholder="Summarize the page's core value proposition"
524
+ />
525
+ </PaneField>
526
+ </SeoAccordionItem>
527
+
528
+ <SeoAccordionItem value="search-appearance" title="Search appearance">
529
+ <p className="text-[11.5px] text-[var(--sub)]">
530
+ Determine how this content should look in search results.
531
+ </p>
532
+ <div className="flex items-center justify-between gap-2">
533
+ <span className="text-[11.5px] font-medium text-[var(--sub)]">
534
+ Google preview
535
+ </span>
536
+ <div className="flex rounded-[7px] border border-[var(--bdr)] p-0.5">
537
+ <button
538
+ type="button"
539
+ aria-pressed={previewDevice === 'mobile'}
540
+ onClick={() => setPreviewDevice('mobile')}
541
+ className={`inline-flex items-center gap-1 rounded-[5px] px-2 py-1 text-[11px] ${previewDevice === 'mobile' ? 'bg-[var(--acc-l)] text-[var(--acc)]' : 'text-[var(--muted)]'}`}
542
+ >
543
+ <Smartphone size={14} aria-hidden />
544
+ Mobile
545
+ </button>
546
+ <button
547
+ type="button"
548
+ aria-pressed={previewDevice === 'desktop'}
549
+ onClick={() => setPreviewDevice('desktop')}
550
+ className={`inline-flex items-center gap-1 rounded-[5px] px-2 py-1 text-[11px] ${previewDevice === 'desktop' ? 'bg-[var(--acc-l)] text-[var(--acc)]' : 'text-[var(--muted)]'}`}
551
+ >
552
+ <Monitor size={14} aria-hidden />
553
+ Desktop
554
+ </button>
397
555
  </div>
398
- )}
399
- <div className="p-2.5">
400
- <p className="truncate text-[10.5px] text-[var(--muted)] uppercase">
401
- {url.replace(/^https?:\/\//, '').split('/')[0]}
402
- </p>
403
- <p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
404
- {form.ogTitle || title}
405
- </p>
406
- <p className="line-clamp-1 text-[12px] text-[var(--sub)]">
407
- {form.ogDescription || desc}
408
- </p>
409
556
  </div>
410
- </div>
411
- </InspectorPaneSection>
412
-
413
- <InspectorPaneSection title="Technical SEO">
414
- <PaneField label="Canonical URL">
415
- <input
416
- className={inputCls}
417
- value={form.canonicalUrl ?? ''}
418
- onChange={(e) => update({ canonicalUrl: e.target.value })}
419
- placeholder={url || 'https://example.com/page'}
420
- />
421
- </PaneField>
422
- {!form.canonicalUrl?.trim() && url ? (
423
- <p className="mb-3 text-[11px] text-[var(--muted)]">
424
- Auto-canonical: {record.canonicalUrl ?? url}
425
- </p>
426
- ) : null}
427
- <PaneField label="Schema type">
428
- <select
429
- className={inputCls}
430
- value={form.structuredDataType ?? 'WebPage'}
431
- onChange={(e) => update({ structuredDataType: e.target.value })}
557
+ <div
558
+ className={`rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3 ${previewDevice === 'mobile' ? 'max-w-[320px]' : ''}`}
432
559
  >
433
- {SEO_SCHEMA_TYPES.map((t) => (
434
- <option key={t} value={t}>
435
- {t}
436
- </option>
437
- ))}
438
- </select>
439
- </PaneField>
440
- <PaneToggleRow
441
- label="No-index"
442
- hint="Hide this page from search engines"
443
- checked={!!form.noindex}
444
- onCheckedChange={(v) => update({ noindex: v })}
445
- />
446
- <PaneToggleRow
447
- label="No-follow"
448
- hint="Tell crawlers not to follow links on this page"
449
- checked={!!form.nofollow}
450
- onCheckedChange={(v) => update({ nofollow: v })}
451
- />
452
- </InspectorPaneSection>
453
-
454
- <InspectorPaneSection title="AI Discovery" subtitle="llms.txt">
455
- <p className="mb-3 text-[11px] text-[var(--muted)]">
456
- Controls how this page appears in the curated <code>llms.txt</code> guide that AI
457
- agents read. It does not affect search ranking.
458
- </p>
459
- <PaneField label="Include in llms.txt">
460
- <select
461
- className={inputCls}
462
- value={form.llmsInclude ?? 'AUTO'}
463
- onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
560
+ <p className="truncate text-[11px] text-[var(--suc)]">{displayUrl}</p>
561
+ <p className="text-[15px] leading-snug text-[var(--acc)]">{renderedTitle}</p>
562
+ <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
563
+ </div>
564
+ <PaneField
565
+ label="SEO title"
566
+ hint={
567
+ <span className="flex items-center gap-2">
568
+ <CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
569
+ {aiButton('metaTitle')}
570
+ </span>
571
+ }
572
+ >
573
+ <input
574
+ className={inputCls}
575
+ value={form.metaTitle ?? ''}
576
+ onChange={(e) => update({ metaTitle: e.target.value })}
577
+ placeholder="Page-specific title (%page% in your site template)"
578
+ />
579
+ </PaneField>
580
+ {alignmentRow('metaTitle')}
581
+ <PaneField
582
+ label="Slug"
583
+ help="URL slug for this content edit in page/post settings."
584
+ >
585
+ <input className={`${inputCls} opacity-80`} value={slug} readOnly disabled />
586
+ </PaneField>
587
+ <PaneField
588
+ label="Meta description"
589
+ hint={
590
+ <span className="flex items-center gap-2">
591
+ <CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
592
+ {aiButton('metaDescription')}
593
+ </span>
594
+ }
464
595
  >
465
- <option value="AUTO">Auto — include when eligible</option>
466
- <option value="ALWAYS">Always pin it in</option>
467
- <option value="NEVER">Never exclude</option>
468
- </select>
469
- </PaneField>
470
- <PaneField
471
- label="Agent description"
472
- hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
596
+ <textarea
597
+ className={`${inputCls} min-h-20 resize-y`}
598
+ value={form.metaDescription ?? ''}
599
+ onChange={(e) => update({ metaDescription: e.target.value })}
600
+ placeholder="Short summary shown under the title in search results"
601
+ />
602
+ </PaneField>
603
+ {alignmentRow('metaDescription')}
604
+ </SeoAccordionItem>
605
+
606
+ <SeoAccordionItem value="social-appearance" title="Social appearance">
607
+ <p className="text-[11.5px] text-[var(--sub)]">
608
+ Customize title, description, and image used when sharing on social platforms.
609
+ </p>
610
+ <div className="flex rounded-[7px] border border-[var(--bdr)] p-0.5">
611
+ {(['facebook', 'twitter'] as const).map((platform) => (
612
+ <button
613
+ key={platform}
614
+ type="button"
615
+ aria-pressed={socialPlatform === platform}
616
+ onClick={() => setSocialPlatform(platform)}
617
+ className={`flex-1 rounded-[5px] px-2 py-1.5 text-[11.5px] font-medium capitalize ${socialPlatform === platform ? 'bg-[var(--acc-l)] text-[var(--acc)]' : 'text-[var(--sub)]'}`}
618
+ >
619
+ {platform === 'twitter' ? 'X / Twitter' : 'Facebook'}
620
+ </button>
621
+ ))}
622
+ </div>
623
+ <div className="overflow-hidden rounded-[7px] border border-[var(--bdr)]">
624
+ {record?.ogImage ? (
625
+ // eslint-disable-next-line @next/next/no-img-element
626
+ <img
627
+ src={record.ogImage}
628
+ alt=""
629
+ className="aspect-[1200/630] w-full bg-[var(--bg)] object-cover"
630
+ />
631
+ ) : (
632
+ <div className="flex aspect-[1200/630] w-full items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--muted)]">
633
+ No social image — add a featured or OG image
634
+ </div>
635
+ )}
636
+ <div className="p-2.5">
637
+ <p className="truncate text-[10.5px] text-[var(--muted)] uppercase">
638
+ {displayUrl.replace(/^https?:\/\//, '').split('/')[0] || 'yoursite.com'}
639
+ </p>
640
+ <p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
641
+ {socialTitle}
642
+ </p>
643
+ <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{socialDesc}</p>
644
+ </div>
645
+ </div>
646
+ {socialPlatform === 'facebook' ? (
647
+ <>
648
+ <PaneField label="OG title" hint={aiButton('ogTitle')}>
649
+ <input
650
+ className={inputCls}
651
+ value={form.ogTitle ?? ''}
652
+ onChange={(e) => update({ ogTitle: e.target.value })}
653
+ placeholder={form.metaTitle || 'Inherits meta title when empty'}
654
+ />
655
+ </PaneField>
656
+ <PaneField label="OG description" hint={aiButton('ogDescription')}>
657
+ <textarea
658
+ className={`${inputCls} min-h-16 resize-y`}
659
+ value={form.ogDescription ?? ''}
660
+ onChange={(e) => update({ ogDescription: e.target.value })}
661
+ placeholder={form.metaDescription || 'Inherits meta description when empty'}
662
+ />
663
+ </PaneField>
664
+ </>
665
+ ) : (
666
+ <>
667
+ <PaneField label="Twitter title">
668
+ <input
669
+ className={inputCls}
670
+ value={form.twitterTitle ?? ''}
671
+ onChange={(e) => update({ twitterTitle: e.target.value })}
672
+ placeholder={form.ogTitle || form.metaTitle || 'Inherits OG/meta title'}
673
+ />
674
+ </PaneField>
675
+ <PaneField label="Twitter description">
676
+ <textarea
677
+ className={`${inputCls} min-h-16 resize-y`}
678
+ value={form.twitterDescription ?? ''}
679
+ onChange={(e) => update({ twitterDescription: e.target.value })}
680
+ placeholder={
681
+ form.ogDescription ||
682
+ form.metaDescription ||
683
+ 'Inherits OG/meta description'
684
+ }
685
+ />
686
+ </PaneField>
687
+ </>
688
+ )}
689
+ {alignmentRow('ogDescription')}
690
+ </SeoAccordionItem>
691
+
692
+ <SeoAccordionItem
693
+ value="seo-analysis"
694
+ title="SEO analysis"
695
+ badge={
696
+ analysis ? (
697
+ <SeoAccordionBadge tone={problemCount > 0 ? 'bad' : 'good'}>
698
+ {problemCount > 0 ? `${problemCount} issues` : 'Looking good'}
699
+ </SeoAccordionBadge>
700
+ ) : undefined
701
+ }
473
702
  >
474
- <textarea
475
- className={`${inputCls} min-h-16 resize-y`}
476
- value={form.llmsDescription ?? ''}
477
- onChange={(e) => update({ llmsDescription: e.target.value })}
478
- placeholder={
479
- form.keyTakeaway ||
480
- form.metaDescription ||
481
- 'One line for an AI agent — what this page covers and why it matters.'
703
+ <SeoAnalysisList
704
+ checks={analysis?.checks ?? []}
705
+ onAiFixField={
706
+ isDraft ? undefined : (field) => void handleAiGenerate(field as AiField)
482
707
  }
708
+ aiFixField={aiBusy}
483
709
  />
484
- </PaneField>
485
- <PaneField label="Section">
486
- <input
487
- className={inputCls}
488
- value={form.llmsSection ?? ''}
489
- onChange={(e) => update({ llmsSection: e.target.value })}
490
- placeholder="Defaults to the content type"
710
+ </SeoAccordionItem>
711
+
712
+ <SeoAccordionItem value="advanced" title="Advanced">
713
+ <PaneField label="Canonical URL">
714
+ <input
715
+ className={inputCls}
716
+ value={form.canonicalUrl ?? ''}
717
+ onChange={(e) => update({ canonicalUrl: e.target.value })}
718
+ placeholder={displayUrl || 'https://example.com/page'}
719
+ />
720
+ </PaneField>
721
+ {!form.canonicalUrl?.trim() && displayUrl && !isDraft && (
722
+ <p className="mb-3 text-[11px] text-[var(--muted)]">
723
+ Auto-canonical: {record?.canonicalUrl ?? displayUrl}
724
+ </p>
725
+ )}
726
+ <PaneField label="Schema type">
727
+ <select
728
+ className={inputCls}
729
+ value={form.structuredDataType ?? 'WebPage'}
730
+ onChange={(e) => update({ structuredDataType: e.target.value })}
731
+ >
732
+ {SEO_SCHEMA_TYPES.map((t) => (
733
+ <option key={t} value={t}>
734
+ {t}
735
+ </option>
736
+ ))}
737
+ </select>
738
+ </PaneField>
739
+ <PaneToggleRow
740
+ label="Allow search engines to show this content?"
741
+ hint="When off, adds a noindex directive"
742
+ checked={!form.noindex}
743
+ onCheckedChange={(v) => update({ noindex: !v })}
491
744
  />
492
- </PaneField>
493
- <PaneToggleRow
494
- label="Optional"
495
- hint="Group under “Optional” — agents skip these first when context is tight"
496
- checked={!!form.llmsOptional}
497
- onCheckedChange={(v) => update({ llmsOptional: v })}
498
- />
499
- </InspectorPaneSection>
500
-
501
- <InspectorPaneSection>
502
- {(() => {
503
- const meta = gradeMeta(record.seoGrade)
504
- return (
505
- <div
506
- className={`flex items-center gap-2 rounded-[7px] px-3 py-2 text-[12.5px] ${meta.bg} ${meta.text}`}
745
+ <PaneToggleRow
746
+ label="Should search engines follow links?"
747
+ hint="When off, adds a nofollow directive"
748
+ checked={!form.nofollow}
749
+ onCheckedChange={(v) => update({ nofollow: !v })}
750
+ />
751
+ <div className="mt-2 border-t border-[var(--bdr)] pt-3">
752
+ <p className="mb-2 text-[11px] font-medium text-[var(--sub)]">
753
+ AI discovery (llms.txt)
754
+ </p>
755
+ <PaneField label="Include in llms.txt">
756
+ <select
757
+ className={inputCls}
758
+ value={form.llmsInclude ?? 'AUTO'}
759
+ onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
760
+ >
761
+ <option value="AUTO">Auto — include when eligible</option>
762
+ <option value="ALWAYS">Always — pin it in</option>
763
+ <option value="NEVER">Never — exclude</option>
764
+ </select>
765
+ </PaneField>
766
+ <PaneField
767
+ label="Agent description"
768
+ hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
507
769
  >
508
- <Check className="h-4 w-4 shrink-0" aria-hidden />
509
- <span>
510
- Current SEO score: <span className="font-medium">{record.seoScore}</span> (
511
- {meta.label})
512
- </span>
513
- </div>
514
- )
515
- })()}
516
- </InspectorPaneSection>
770
+ <textarea
771
+ className={`${inputCls} min-h-16 resize-y`}
772
+ value={form.llmsDescription ?? ''}
773
+ onChange={(e) => update({ llmsDescription: e.target.value })}
774
+ placeholder={
775
+ form.keyTakeaway ||
776
+ form.metaDescription ||
777
+ 'One line for an AI agent — what this page covers'
778
+ }
779
+ />
780
+ </PaneField>
781
+ </div>
782
+ </SeoAccordionItem>
783
+
784
+ <SeoAccordionItem value="insights" title="Insights">
785
+ <SeoInsightsMetrics readability={readability} plainText={plainText} />
786
+ </SeoAccordionItem>
787
+
788
+ <SeoAccordionItem value="linking-data" title="Linking data">
789
+ <SeoLinkingSection
790
+ entityType={entityType}
791
+ entityId={entityId}
792
+ internalLinks={Number(internalLinks)}
793
+ externalLinks={Number(externalLinks)}
794
+ internalLinkOk={internalOk}
795
+ externalLinkOk={externalOk}
796
+ />
797
+ </SeoAccordionItem>
798
+ </SeoAccordion>
517
799
  </>
518
- ) : null}
800
+ )}
519
801
  </InspectorPaneBody>
520
802
 
521
803
  <InspectorPaneFooter>
@@ -525,7 +807,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
525
807
  disabled={saving || loading || !!error}
526
808
  className="bg-primary text-primary-foreground w-full rounded-[7px] px-3 py-2 text-[13px] font-semibold transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
527
809
  >
528
- {saving ? 'Saving…' : 'Save Changes'}
810
+ {saving ? 'Saving…' : isDraft ? 'Done' : 'Save Changes'}
529
811
  </button>
530
812
  </InspectorPaneFooter>
531
813
  </>