@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.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/EditorSeoAside.d.ts +7 -4
- package/dist/components/EditorSeoAside.d.ts.map +1 -1
- package/dist/components/EditorSeoAside.js +7 -16
- package/dist/components/EditorSeoAside.js.map +1 -1
- package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +131 -31
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.js.map +1 -0
- package/dist/lib/seo-service.d.ts +2 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +5 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -4
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +13 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +2 -13
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +14 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
- package/src/components/EditorSeoAside.tsx +27 -108
- package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +497 -244
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/seo-service.ts +7 -0
- package/src/views/page-editor/EditorTopBar.tsx +7 -4
- package/src/views/page-editor/PageSectionEditor.tsx +15 -0
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostSectionEditor.tsx +17 -0
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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:
|
|
80
|
-
hint?:
|
|
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:
|
|
129
|
-
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
|
290
|
-
const
|
|
400
|
+
const analysis = liveAnalysis ?? remoteAnalysis
|
|
401
|
+
const plainText = stripHtmlTags(contentHtml)
|
|
402
|
+
const readability = useMemo(() => calculateReadability(plainText), [plainText])
|
|
291
403
|
|
|
292
|
-
|
|
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 ?? '
|
|
440
|
+
{record?.title ?? draftMeta?.seoTitle ?? 'SEO'}
|
|
311
441
|
</div>
|
|
312
|
-
{
|
|
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
|
-
) :
|
|
456
|
+
) : (
|
|
325
457
|
<>
|
|
326
|
-
|
|
327
|
-
<div className="
|
|
328
|
-
<p className="
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
<
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
<
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
<
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
<
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
{
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
)
|
|
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
|
</>
|