@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.
- package/CHANGELOG.md +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +13 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -1
- 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/redirects-pane.render.test.js +6 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +15 -4
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/EditorSeoAside.d.ts +23 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +21 -0
- package/dist/components/EditorSeoAside.js.map +1 -0
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SchedulePublishDialog.js +1 -1
- package/dist/components/SharePreviewLinkDialog.js +1 -1
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +2 -1
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/VersionHistory.js +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 +155 -35
- 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/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +13 -3
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- 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/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +3 -3
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/Card.js +5 -5
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/InspectorPane.js +3 -3
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/PanelSection.d.ts +16 -0
- package/dist/components/ui/PanelSection.d.ts.map +1 -0
- package/dist/components/ui/PanelSection.js +15 -0
- package/dist/components/ui/PanelSection.js.map +1 -0
- package/dist/lib/admin-root-element.d.ts +9 -0
- package/dist/lib/admin-root-element.d.ts.map +1 -0
- package/dist/lib/admin-root-element.js +14 -0
- package/dist/lib/admin-root-element.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/portal-container.d.ts.map +1 -1
- package/dist/lib/portal-container.js +2 -3
- package/dist/lib/portal-container.js.map +1 -1
- package/dist/lib/seo-service.d.ts +91 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +55 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +12 -14
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +35 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/Posts/PostTypeEditor.js +3 -3
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +4 -2
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -2
- 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 +36 -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/page-editor/SectionInspector.js +2 -2
- package/dist/views/page-editor/SectionsPanel.js +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/PostHeaderPanel.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +37 -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/dist/views/seo/LinksTab.d.ts +4 -0
- package/dist/views/seo/LinksTab.d.ts.map +1 -0
- package/dist/views/seo/LinksTab.js +111 -0
- package/dist/views/seo/LinksTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +39 -6
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/EmailSettingsTab.js +2 -2
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/SecurityStatusCard.js +1 -1
- package/dist/views/settings/SeoSettingsTab.js +4 -4
- package/dist/views/settings/UpdatesTab.js +2 -2
- package/dist/views/settings/components.js +3 -3
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +3 -2
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
- package/src/__tests__/views/portal-container.test.tsx +14 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +68 -0
- package/src/components/MediaPickerModal.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +2 -2
- package/src/components/SchedulePublishDialog.tsx +1 -1
- package/src/components/SharePreviewLinkDialog.tsx +1 -1
- package/src/components/ThemeProvider.tsx +3 -1
- package/src/components/VersionHistory.tsx +1 -1
- 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 +532 -250
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/components/seo/primitives.tsx +7 -3
- package/src/components/ui/Card.tsx +5 -5
- package/src/components/ui/InspectorPane.tsx +3 -3
- package/src/components/ui/PanelSection.tsx +53 -0
- package/src/lib/admin-root-element.ts +15 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +150 -2
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/Dashboard.tsx +40 -53
- package/src/views/MediaBrowser.tsx +62 -12
- package/src/views/PageEditor.tsx +1 -1
- package/src/views/Posts/PostTypeEditor.tsx +3 -3
- package/src/views/SEO.tsx +6 -0
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/Users.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +27 -0
- package/src/views/page-editor/PageSectionEditor.tsx +58 -3
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/page-editor/SectionInspector.tsx +2 -2
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +60 -3
- package/src/views/seo/ContentTab.tsx +11 -5
- package/src/views/seo/LinksTab.tsx +462 -0
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +97 -4
- package/src/views/seo/TechnicalTab.tsx +2 -2
- package/src/views/settings/AISettingsTab.tsx +3 -3
- package/src/views/settings/AiFeaturesCard.tsx +1 -1
- package/src/views/settings/AppearanceTab.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +2 -2
- package/src/views/settings/EmailSettingsTab.tsx +2 -2
- package/src/views/settings/GeneralSettingsTab.tsx +3 -3
- package/src/views/settings/SecurityStatusCard.tsx +1 -1
- package/src/views/settings/SeoSettingsTab.tsx +6 -6
- package/src/views/settings/UpdatesTab.tsx +3 -3
- 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
|
|
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
|
-
import {
|
|
11
|
-
|
|
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:
|
|
71
|
-
hint?:
|
|
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:
|
|
120
|
-
|
|
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({
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, [
|
|
279
|
+
setAiAlignments((prev) => ({ ...prev, [targetField]: result.brandAlignment }))
|
|
188
280
|
}
|
|
189
|
-
toast.success(
|
|
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
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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 ?? '
|
|
440
|
+
{record?.title ?? draftMeta?.seoTitle ?? 'SEO'}
|
|
282
441
|
</div>
|
|
283
|
-
{
|
|
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-
|
|
449
|
+
<div className="px-5 py-4">
|
|
289
450
|
<SeoLoading />
|
|
290
451
|
</div>
|
|
291
452
|
) : error ? (
|
|
292
|
-
<div className="px-
|
|
453
|
+
<div className="px-5 py-4">
|
|
293
454
|
<SeoErrorState message={error} />
|
|
294
455
|
</div>
|
|
295
|
-
) :
|
|
456
|
+
) : (
|
|
296
457
|
<>
|
|
297
|
-
|
|
298
|
-
<div className="
|
|
299
|
-
<p className="
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
<
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
<
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
</
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
{
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
)
|
|
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
|
</>
|