@actuate-media/cms-admin 0.74.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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
  3. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  4. package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
  5. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  6. package/dist/actuate-admin.css +1 -1
  7. package/dist/components/EditorSeoAside.d.ts +7 -4
  8. package/dist/components/EditorSeoAside.d.ts.map +1 -1
  9. package/dist/components/EditorSeoAside.js +7 -16
  10. package/dist/components/EditorSeoAside.js.map +1 -1
  11. package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
  12. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
  13. package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
  14. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
  15. package/dist/components/seo/SeoAccordion.d.ts +16 -0
  16. package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
  17. package/dist/components/seo/SeoAccordion.js +21 -0
  18. package/dist/components/seo/SeoAccordion.js.map +1 -0
  19. package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
  20. package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
  21. package/dist/components/seo/SeoAnalysisList.js +32 -0
  22. package/dist/components/seo/SeoAnalysisList.js.map +1 -0
  23. package/dist/components/seo/SeoEditorPane.d.ts +15 -4
  24. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  25. package/dist/components/seo/SeoEditorPane.js +131 -31
  26. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  27. package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
  28. package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
  29. package/dist/components/seo/SeoInsightsMetrics.js +16 -0
  30. package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
  31. package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
  32. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
  33. package/dist/components/seo/SeoLinkingSection.js +44 -0
  34. package/dist/components/seo/SeoLinkingSection.js.map +1 -0
  35. package/dist/lib/editor-seo-content.d.ts +16 -0
  36. package/dist/lib/editor-seo-content.d.ts.map +1 -0
  37. package/dist/lib/editor-seo-content.js +139 -0
  38. package/dist/lib/editor-seo-content.js.map +1 -0
  39. package/dist/lib/seo-service.d.ts +2 -0
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +5 -0
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
  44. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  45. package/dist/views/page-editor/EditorTopBar.js +5 -4
  46. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  47. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  48. package/dist/views/page-editor/PageSectionEditor.js +13 -1
  49. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  50. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  51. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  52. package/dist/views/page-editor/PageSettingsModal.js +2 -13
  53. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  54. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  55. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  56. package/dist/views/post-editor/PostFieldsModal.js +17 -79
  57. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  58. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  59. package/dist/views/post-editor/PostSectionEditor.js +14 -1
  60. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  61. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  62. package/dist/views/seo/ContentTab.js +7 -1
  63. package/dist/views/seo/ContentTab.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
  66. package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
  67. package/src/components/EditorSeoAside.tsx +27 -108
  68. package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
  69. package/src/components/seo/SeoAccordion.tsx +76 -0
  70. package/src/components/seo/SeoAnalysisList.tsx +113 -0
  71. package/src/components/seo/SeoEditorPane.tsx +497 -244
  72. package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
  73. package/src/components/seo/SeoLinkingSection.tsx +151 -0
  74. package/src/lib/editor-seo-content.ts +144 -0
  75. package/src/lib/seo-service.ts +7 -0
  76. package/src/views/page-editor/EditorTopBar.tsx +7 -4
  77. package/src/views/page-editor/PageSectionEditor.tsx +15 -0
  78. package/src/views/page-editor/PageSettingsModal.tsx +1 -45
  79. package/src/views/post-editor/PostFieldsModal.tsx +19 -138
  80. package/src/views/post-editor/PostSectionEditor.tsx +17 -0
  81. package/src/views/seo/ContentTab.tsx +11 -5
@@ -1,11 +1,9 @@
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
8
  import {
11
9
  cloneElement,
@@ -15,15 +13,23 @@ import {
15
13
  useMemo,
16
14
  useState,
17
15
  type ReactElement,
16
+ type ReactNode,
18
17
  } from 'react'
19
- import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
18
+ import { Loader2, Monitor, Smartphone, Sparkles, Wand2 } from 'lucide-react'
20
19
  import { toast } from 'sonner'
21
20
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
22
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'
23
28
  import {
24
29
  fetchSeoContentRecord,
25
30
  updateSeoFields,
26
31
  generateSeoField,
32
+ fetchDocumentSeoAnalysis,
27
33
  SEO_SCHEMA_TYPES,
28
34
  type SeoContentRecord,
29
35
  type SeoFieldPayload,
@@ -31,14 +37,13 @@ import {
31
37
  } from '../../lib/seo-service.js'
32
38
  import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
33
39
  import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
34
- import {
35
- InspectorPaneBody,
36
- InspectorPaneFooter,
37
- InspectorPaneHeader,
38
- InspectorPaneSection,
39
- } from '../ui/InspectorPane.js'
40
+ import { InspectorPaneBody, InspectorPaneFooter, InspectorPaneHeader } from '../ui/InspectorPane.js'
40
41
  import { Toggle } from '../ui/Toggle.js'
41
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'
42
47
 
43
48
  const META_TITLE_MAX = 60
44
49
  const META_DESC_MAX = 160
@@ -74,14 +79,13 @@ function PaneField({
74
79
  label,
75
80
  children,
76
81
  hint,
82
+ help,
77
83
  }: {
78
84
  label: string
79
- children: React.ReactNode
80
- hint?: React.ReactNode
85
+ children: ReactNode
86
+ hint?: ReactNode
87
+ help?: string
81
88
  }) {
82
- // Associate the visible label with its control for screen readers. We inject
83
- // a generated id onto the single child input/textarea (unless it already has
84
- // one) and point the <label htmlFor> at it.
85
89
  const generatedId = useId()
86
90
  let control = children
87
91
  if (isValidElement(children) && (children.props as { id?: string }).id == null) {
@@ -98,6 +102,7 @@ function PaneField({
98
102
  {hint}
99
103
  </div>
100
104
  {control}
105
+ {help && <p className="mt-1 text-[11px] text-[var(--muted)]">{help}</p>}
101
106
  </div>
102
107
  )
103
108
  }
@@ -125,16 +130,31 @@ function PaneToggleRow({
125
130
  }
126
131
 
127
132
  export interface SeoEditorPaneProps {
128
- entityType: string
129
- entityId: string
133
+ entityType: 'page' | 'post'
134
+ /** When absent, draft-only mode — meta fields sync via `onDraftChange`. */
135
+ entityId: string | null
130
136
  onClose: () => void
131
- /** Called after a successful save so the list can refetch. */
132
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
133
143
  }
134
144
 
135
- 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
136
156
  const [record, setRecord] = useState<SeoContentRecord | null>(null)
137
- const [loading, setLoading] = useState(true)
157
+ const [loading, setLoading] = useState(!isDraft)
138
158
  const [error, setError] = useState<string | null>(null)
139
159
  const [saving, setSaving] = useState(false)
140
160
  const [aiBusy, setAiBusy] = useState<AiField | null>(null)
@@ -143,8 +163,34 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
143
163
  Partial<Record<AiField, BrandAlignmentScoreResult>>
144
164
  >({})
145
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)
146
169
 
147
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
+
148
194
  let alive = true
149
195
  setLoading(true)
150
196
  setError(null)
@@ -164,6 +210,8 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
164
210
  keyTakeaway: rec.keyTakeaway ?? '',
165
211
  ogTitle: rec.ogTitle ?? '',
166
212
  ogDescription: rec.ogDescription ?? '',
213
+ twitterTitle: rec.twitterTitle ?? '',
214
+ twitterDescription: rec.twitterDescription ?? '',
167
215
  structuredDataType: rec.structuredDataType ?? 'WebPage',
168
216
  noindex: rec.noindex,
169
217
  nofollow: rec.nofollow,
@@ -178,14 +226,44 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
178
226
  return () => {
179
227
  alive = false
180
228
  }
181
- }, [entityType, entityId])
229
+ }, [entityType, entityId, isDraft])
182
230
 
183
- 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
+ }
184
252
 
185
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
+
186
259
  setAiBusy(field)
187
260
  try {
188
- 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! })
189
267
  if (result.unavailable) {
190
268
  toast.error('AI is not configured. Add an AI provider to enable suggestions.')
191
269
  return
@@ -202,7 +280,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
202
280
  }
203
281
  toast.success(
204
282
  field === 'ogTitle' || field === 'ogDescription'
205
- ? 'AI suggestion applied to meta tags — OG inherits from meta unless you override.'
283
+ ? 'AI suggestion applied to meta tags — social fields inherit unless overridden.'
206
284
  : 'AI suggestion applied — review before saving.',
207
285
  )
208
286
  } else {
@@ -263,8 +341,13 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
263
341
  }
264
342
 
265
343
  async function handleSave() {
344
+ if (isDraft) {
345
+ toast.success('SEO fields will be saved with the document.')
346
+ onClose()
347
+ return
348
+ }
266
349
  setSaving(true)
267
- const res = await updateSeoFields(entityType, entityId, form)
350
+ const res = await updateSeoFields(entityType, entityId!, form)
268
351
  setSaving(false)
269
352
  if (res.error) {
270
353
  toast.error(res.error)
@@ -275,6 +358,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
275
358
  }
276
359
 
277
360
  const renderedTitle = useMemo(() => {
361
+ if (isDraft) return form.metaTitle?.trim() || draftMeta?.seoTitle || 'Untitled'
278
362
  if (!record) return form.metaTitle?.trim() || 'Untitled'
279
363
  return resolveRenderedMetaTitle({
280
364
  metaTitlePage: form.metaTitle,
@@ -284,17 +368,54 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
284
368
  titleTemplate: record.titleTemplate ?? undefined,
285
369
  },
286
370
  })
287
- }, [form.metaTitle, record])
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])
288
399
 
289
- const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
290
- const url = record?.url ?? ''
400
+ const analysis = liveAnalysis ?? remoteAnalysis
401
+ const plainText = stripHtmlTags(contentHtml)
402
+ const readability = useMemo(() => calculateReadability(plainText), [plainText])
291
403
 
292
- function aiButton(field: AiField) {
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
411
+
412
+ function aiButton(field: AiField, label?: string) {
293
413
  return (
294
414
  <button
295
415
  type="button"
296
416
  onClick={() => void handleAiGenerate(field)}
297
- disabled={aiBusy !== null}
417
+ disabled={aiBusy !== null || isDraft}
418
+ title={isDraft ? 'Save a draft first' : label}
298
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"
299
420
  >
300
421
  <Sparkles className={`h-3 w-3 ${aiBusy === field ? 'animate-pulse' : ''}`} aria-hidden />
@@ -303,13 +424,24 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
303
424
  )
304
425
  }
305
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
+
306
436
  return (
307
437
  <>
308
438
  <InspectorPaneHeader onClose={onClose} closeLabel="Close SEO editor">
309
439
  <div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
310
- {record?.title ?? 'Edit SEO'}
440
+ {record?.title ?? draftMeta?.seoTitle ?? 'SEO'}
311
441
  </div>
312
- {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
+ )}
313
445
  </InspectorPaneHeader>
314
446
 
315
447
  <InspectorPaneBody>
@@ -321,230 +453,351 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
321
453
  <div className="px-5 py-4">
322
454
  <SeoErrorState message={error} />
323
455
  </div>
324
- ) : record ? (
456
+ ) : (
325
457
  <>
326
- <InspectorPaneSection title="Google Preview">
327
- <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
328
- <p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
329
- <p className="text-[15px] leading-snug text-[var(--acc)]">{renderedTitle}</p>
330
- <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>
331
464
  </div>
332
- </InspectorPaneSection>
333
-
334
- <InspectorPaneSection title="Meta Tags">
335
- <PaneField
336
- label="Meta title"
337
- hint={
338
- <span className="flex items-center gap-2">
339
- <CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
340
- {aiButton('metaTitle')}
341
- </span>
342
- }
343
- >
344
- <input
345
- className={inputCls}
346
- value={form.metaTitle ?? ''}
347
- onChange={(e) => update({ metaTitle: e.target.value })}
348
- placeholder="Page-specific title (%page% in your site template)"
349
- />
350
- </PaneField>
351
- <PaneField
352
- label="Meta description"
353
- hint={
354
- <span className="flex items-center gap-2">
355
- <CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
356
- {aiButton('metaDescription')}
357
- </span>
358
- }
359
- >
360
- <textarea
361
- className={`${inputCls} min-h-20 resize-y`}
362
- value={form.metaDescription ?? ''}
363
- onChange={(e) => update({ metaDescription: e.target.value })}
364
- placeholder="Short summary shown under the title in search results"
365
- />
366
- </PaneField>
367
- {alignmentRow('metaDescription')}
368
- </InspectorPaneSection>
369
-
370
- <InspectorPaneSection title="Focus Keyword">
371
- <PaneField label="Target keyword" hint={aiButton('focusKeyword')}>
372
- <input
373
- className={inputCls}
374
- value={form.focusKeyword ?? ''}
375
- onChange={(e) => update({ focusKeyword: e.target.value })}
376
- placeholder="Primary keyword this page targets"
377
- />
378
- </PaneField>
379
- <PaneField
380
- label="Key takeaway"
381
- hint={
382
- <span className="flex items-center gap-2">
383
- <CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
384
- {aiButton('keyTakeaway')}
385
- </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
+ )
386
495
  }
387
496
  >
388
- <textarea
389
- className={`${inputCls} min-h-16 resize-y`}
390
- value={form.keyTakeaway ?? ''}
391
- onChange={(e) => update({ keyTakeaway: e.target.value })}
392
- placeholder="One or two sentences AI answer engines can quote — what is this page about?"
393
- />
394
- </PaneField>
395
- </InspectorPaneSection>
396
-
397
- <InspectorPaneSection title="Open Graph" subtitle="Social sharing">
398
- <PaneField label="OG title" hint={aiButton('ogTitle')}>
399
- <input
400
- className={inputCls}
401
- value={form.ogTitle ?? ''}
402
- onChange={(e) => update({ ogTitle: e.target.value })}
403
- placeholder={form.metaTitle || 'Title when shared on social media'}
404
- />
405
- </PaneField>
406
- <PaneField label="OG description" hint={aiButton('ogDescription')}>
407
- <textarea
408
- className={`${inputCls} min-h-16 resize-y`}
409
- value={form.ogDescription ?? ''}
410
- onChange={(e) => update({ ogDescription: e.target.value })}
411
- placeholder={form.metaDescription || 'Description when shared on social media'}
412
- />
413
- </PaneField>
414
- {alignmentRow('ogDescription')}
415
- <div className="mt-3 overflow-hidden rounded-[7px] border border-[var(--bdr)]">
416
- {record.ogImage ? (
417
- // eslint-disable-next-line @next/next/no-img-element
418
- <img
419
- src={record.ogImage}
420
- alt={`Social preview for ${record.title}`}
421
- 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"
422
507
  />
423
- ) : (
424
- <div className="flex aspect-[1200/630] w-full items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--muted)]">
425
- 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>
426
555
  </div>
427
- )}
428
- <div className="p-2.5">
429
- <p className="truncate text-[10.5px] text-[var(--muted)] uppercase">
430
- {url.replace(/^https?:\/\//, '').split('/')[0]}
431
- </p>
432
- <p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
433
- {form.ogTitle || renderedTitle}
434
- </p>
435
- <p className="line-clamp-1 text-[12px] text-[var(--sub)]">
436
- {form.ogDescription || form.metaDescription || desc}
437
- </p>
438
556
  </div>
439
- </div>
440
- </InspectorPaneSection>
441
-
442
- <InspectorPaneSection title="Technical SEO">
443
- <PaneField label="Canonical URL">
444
- <input
445
- className={inputCls}
446
- value={form.canonicalUrl ?? ''}
447
- onChange={(e) => update({ canonicalUrl: e.target.value })}
448
- placeholder={url || 'https://example.com/page'}
449
- />
450
- </PaneField>
451
- {!form.canonicalUrl?.trim() && url ? (
452
- <p className="mb-3 text-[11px] text-[var(--muted)]">
453
- Auto-canonical: {record.canonicalUrl ?? url}
454
- </p>
455
- ) : null}
456
- <PaneField label="Schema type">
457
- <select
458
- className={inputCls}
459
- value={form.structuredDataType ?? 'WebPage'}
460
- 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]' : ''}`}
461
559
  >
462
- {SEO_SCHEMA_TYPES.map((t) => (
463
- <option key={t} value={t}>
464
- {t}
465
- </option>
466
- ))}
467
- </select>
468
- </PaneField>
469
- <PaneToggleRow
470
- label="No-index"
471
- hint="Hide this page from search engines"
472
- checked={!!form.noindex}
473
- onCheckedChange={(v) => update({ noindex: v })}
474
- />
475
- <PaneToggleRow
476
- label="No-follow"
477
- hint="Tell crawlers not to follow links on this page"
478
- checked={!!form.nofollow}
479
- onCheckedChange={(v) => update({ nofollow: v })}
480
- />
481
- </InspectorPaneSection>
482
-
483
- <InspectorPaneSection title="AI Discovery" subtitle="llms.txt">
484
- <p className="mb-3 text-[11px] text-[var(--muted)]">
485
- Controls how this page appears in the curated <code>llms.txt</code> guide that AI
486
- agents read. It does not affect search ranking.
487
- </p>
488
- <PaneField label="Include in llms.txt">
489
- <select
490
- className={inputCls}
491
- value={form.llmsInclude ?? 'AUTO'}
492
- 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
+ }
493
572
  >
494
- <option value="AUTO">Auto — include when eligible</option>
495
- <option value="ALWAYS">Always — pin it in</option>
496
- <option value="NEVER">Never exclude</option>
497
- </select>
498
- </PaneField>
499
- <PaneField
500
- label="Agent description"
501
- hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
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
+ }
595
+ >
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
+ }
502
702
  >
503
- <textarea
504
- className={`${inputCls} min-h-16 resize-y`}
505
- value={form.llmsDescription ?? ''}
506
- onChange={(e) => update({ llmsDescription: e.target.value })}
507
- placeholder={
508
- form.keyTakeaway ||
509
- form.metaDescription ||
510
- '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)
511
707
  }
708
+ aiFixField={aiBusy}
709
+ />
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 })}
512
744
  />
513
- </PaneField>
514
- <PaneField label="Section">
515
- <input
516
- className={inputCls}
517
- value={form.llmsSection ?? ''}
518
- onChange={(e) => update({ llmsSection: e.target.value })}
519
- placeholder="Defaults to the content type"
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 })}
520
750
  />
521
- </PaneField>
522
- <PaneToggleRow
523
- label="Optional"
524
- hint="Group under “Optional” — agents skip these first when context is tight"
525
- checked={!!form.llmsOptional}
526
- onCheckedChange={(v) => update({ llmsOptional: v })}
527
- />
528
- </InspectorPaneSection>
529
-
530
- <InspectorPaneSection>
531
- {(() => {
532
- const meta = gradeMeta(record.seoGrade)
533
- return (
534
- <div
535
- className={`flex items-center gap-2 rounded-[7px] px-3 py-2 text-[12.5px] ${meta.bg} ${meta.text}`}
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} />}
536
769
  >
537
- <Check className="h-4 w-4 shrink-0" aria-hidden />
538
- <span>
539
- Current SEO score: <span className="font-medium">{record.seoScore}</span> (
540
- {meta.label})
541
- </span>
542
- </div>
543
- )
544
- })()}
545
- </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>
546
799
  </>
547
- ) : null}
800
+ )}
548
801
  </InspectorPaneBody>
549
802
 
550
803
  <InspectorPaneFooter>
@@ -554,7 +807,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
554
807
  disabled={saving || loading || !!error}
555
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"
556
809
  >
557
- {saving ? 'Saving…' : 'Save Changes'}
810
+ {saving ? 'Saving…' : isDraft ? 'Done' : 'Save Changes'}
558
811
  </button>
559
812
  </InspectorPaneFooter>
560
813
  </>