@actuate-media/cms-admin 0.67.0 → 0.68.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 +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +29 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +36 -7
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
|
@@ -1,529 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
|
|
4
|
-
import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
|
|
5
|
-
import { toast } from 'sonner'
|
|
6
|
-
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
7
|
-
import {
|
|
8
|
-
fetchSeoContentRecord,
|
|
9
|
-
updateSeoFields,
|
|
10
|
-
generateSeoField,
|
|
11
|
-
SEO_SCHEMA_TYPES,
|
|
12
|
-
type SeoContentRecord,
|
|
13
|
-
type SeoFieldPayload,
|
|
14
|
-
type LlmsInclude,
|
|
15
|
-
} from '../../lib/seo-service.js'
|
|
16
|
-
import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
|
|
17
|
-
import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
|
|
18
|
-
import { Drawer } from './Drawer.js'
|
|
19
|
-
import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from './primitives.js'
|
|
20
|
-
import { Toggle } from '../ui/Toggle.js'
|
|
21
|
-
|
|
22
|
-
const META_TITLE_MAX = 60
|
|
23
|
-
const META_DESC_MAX = 160
|
|
24
|
-
|
|
25
|
-
type AiField =
|
|
26
|
-
| 'metaTitle'
|
|
27
|
-
| 'metaDescription'
|
|
28
|
-
| 'ogTitle'
|
|
29
|
-
| 'ogDescription'
|
|
30
|
-
| 'focusKeyword'
|
|
31
|
-
| 'keyTakeaway'
|
|
32
|
-
|
|
33
|
-
const KEY_TAKEAWAY_MAX = 240
|
|
34
|
-
const LLMS_DESC_MAX = 160
|
|
35
|
-
|
|
36
|
-
function seoBrandChannel(field: AiField): string | undefined {
|
|
37
|
-
if (field === 'metaDescription' || field === 'ogDescription') return 'meta_description'
|
|
38
|
-
return undefined
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function CharCount({ value, max }: { value: string; max: number }) {
|
|
42
|
-
const over = value.length > max
|
|
43
|
-
return (
|
|
44
|
-
<span className={`text-xs ${over ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
45
|
-
{value.length}/{max}
|
|
46
|
-
</span>
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function Field({
|
|
51
|
-
label,
|
|
52
|
-
children,
|
|
53
|
-
hint,
|
|
54
|
-
}: {
|
|
55
|
-
label: string
|
|
56
|
-
children: React.ReactNode
|
|
57
|
-
hint?: React.ReactNode
|
|
58
|
-
}) {
|
|
59
|
-
// Associate the visible label with its control for screen readers. We inject
|
|
60
|
-
// a generated id onto the single child input/textarea (unless it already has
|
|
61
|
-
// one) and point the <label htmlFor> at it.
|
|
62
|
-
const generatedId = useId()
|
|
63
|
-
let control = children
|
|
64
|
-
if (isValidElement(children) && (children.props as { id?: string }).id == null) {
|
|
65
|
-
control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
|
|
66
|
-
}
|
|
67
|
-
const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<div className="space-y-1.5">
|
|
71
|
-
<div className="flex items-center justify-between">
|
|
72
|
-
<label htmlFor={controlId} className="text-foreground text-sm font-medium">
|
|
73
|
-
{label}
|
|
74
|
-
</label>
|
|
75
|
-
{hint}
|
|
76
|
-
</div>
|
|
77
|
-
{control}
|
|
78
|
-
</div>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const inputCls =
|
|
83
|
-
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
84
|
-
|
|
85
|
-
export function SeoEditorDrawer({
|
|
86
|
-
open,
|
|
87
|
-
onOpenChange,
|
|
88
|
-
entityType,
|
|
89
|
-
entityId,
|
|
90
|
-
onSaved,
|
|
91
|
-
}: {
|
|
92
|
-
open: boolean
|
|
93
|
-
onOpenChange: (open: boolean) => void
|
|
94
|
-
entityType: string | null
|
|
95
|
-
entityId: string | null
|
|
96
|
-
onSaved?: () => void
|
|
97
|
-
}) {
|
|
98
|
-
const [record, setRecord] = useState<SeoContentRecord | null>(null)
|
|
99
|
-
const [loading, setLoading] = useState(false)
|
|
100
|
-
const [error, setError] = useState<string | null>(null)
|
|
101
|
-
const [saving, setSaving] = useState(false)
|
|
102
|
-
const [aiBusy, setAiBusy] = useState<AiField | null>(null)
|
|
103
|
-
const [aiFixBusy, setAiFixBusy] = useState<AiField | null>(null)
|
|
104
|
-
const [aiAlignments, setAiAlignments] = useState<
|
|
105
|
-
Partial<Record<AiField, BrandAlignmentScoreResult>>
|
|
106
|
-
>({})
|
|
107
|
-
const [form, setForm] = useState<SeoFieldPayload>({})
|
|
108
|
-
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (!open || !entityType || !entityId) return
|
|
111
|
-
let alive = true
|
|
112
|
-
setLoading(true)
|
|
113
|
-
setError(null)
|
|
114
|
-
fetchSeoContentRecord(entityType, entityId)
|
|
115
|
-
.then((rec) => {
|
|
116
|
-
if (!alive) return
|
|
117
|
-
if (!rec) {
|
|
118
|
-
setError('Could not load this record.')
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
setRecord(rec)
|
|
122
|
-
setForm({
|
|
123
|
-
metaTitle: rec.metaTitle ?? '',
|
|
124
|
-
metaDescription: rec.metaDescription ?? '',
|
|
125
|
-
canonicalUrl: rec.canonicalUrl ?? '',
|
|
126
|
-
focusKeyword: rec.focusKeyword ?? '',
|
|
127
|
-
keyTakeaway: rec.keyTakeaway ?? '',
|
|
128
|
-
ogTitle: rec.ogTitle ?? '',
|
|
129
|
-
ogDescription: rec.ogDescription ?? '',
|
|
130
|
-
structuredDataType: rec.structuredDataType ?? 'WebPage',
|
|
131
|
-
noindex: rec.noindex,
|
|
132
|
-
nofollow: rec.nofollow,
|
|
133
|
-
llmsInclude: rec.llmsInclude ?? 'AUTO',
|
|
134
|
-
llmsDescription: rec.llmsDescription ?? '',
|
|
135
|
-
llmsSection: rec.llmsSection ?? '',
|
|
136
|
-
llmsOptional: rec.llmsOptional ?? false,
|
|
137
|
-
})
|
|
138
|
-
})
|
|
139
|
-
.catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
140
|
-
.finally(() => alive && setLoading(false))
|
|
141
|
-
return () => {
|
|
142
|
-
alive = false
|
|
143
|
-
}
|
|
144
|
-
}, [open, entityType, entityId])
|
|
145
|
-
|
|
146
|
-
const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
|
|
147
|
-
|
|
148
|
-
async function handleAiGenerate(field: AiField) {
|
|
149
|
-
if (!entityType || !entityId) return
|
|
150
|
-
setAiBusy(field)
|
|
151
|
-
try {
|
|
152
|
-
const result = await generateSeoField(field, { entityType, entityId })
|
|
153
|
-
if (result.unavailable) {
|
|
154
|
-
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
if (result.text) {
|
|
158
|
-
update({ [field]: result.text } as Partial<SeoFieldPayload>)
|
|
159
|
-
if (result.brandAlignment) {
|
|
160
|
-
setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
|
|
161
|
-
}
|
|
162
|
-
toast.success('AI suggestion applied — review before saving.')
|
|
163
|
-
} else {
|
|
164
|
-
toast.error('No suggestion was returned.')
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
toast.error('AI request failed.')
|
|
168
|
-
} finally {
|
|
169
|
-
setAiBusy(null)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function handleAiFix(field: AiField) {
|
|
174
|
-
const text = (form[field as keyof SeoFieldPayload] as string | undefined) ?? ''
|
|
175
|
-
if (!text.trim()) return
|
|
176
|
-
setAiFixBusy(field)
|
|
177
|
-
try {
|
|
178
|
-
const res = await fixBrandVoiceContent({
|
|
179
|
-
content: text,
|
|
180
|
-
channel: seoBrandChannel(field),
|
|
181
|
-
})
|
|
182
|
-
if (!res.ok || !res.result) {
|
|
183
|
-
toast.error(res.error ?? 'Could not fix copy for brand voice.')
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
update({ [field]: res.result.text } as Partial<SeoFieldPayload>)
|
|
187
|
-
setAiAlignments((prev) => ({ ...prev, [field]: res.result!.afterScore }))
|
|
188
|
-
toast.success('Copy adjusted to match brand voice.')
|
|
189
|
-
} finally {
|
|
190
|
-
setAiFixBusy(null)
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function alignmentRow(field: AiField) {
|
|
195
|
-
const alignment = aiAlignments[field]
|
|
196
|
-
if (!alignment) return null
|
|
197
|
-
const fixing = aiFixBusy === field
|
|
198
|
-
return (
|
|
199
|
-
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
200
|
-
<BrandVoiceAlignmentBadge alignment={alignment} compact />
|
|
201
|
-
{alignment.score < 85 && (
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
onClick={() => handleAiFix(field)}
|
|
205
|
-
disabled={fixing || aiBusy !== null}
|
|
206
|
-
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
207
|
-
>
|
|
208
|
-
{fixing ? (
|
|
209
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
210
|
-
) : (
|
|
211
|
-
<Wand2 className="h-3.5 w-3.5" aria-hidden />
|
|
212
|
-
)}
|
|
213
|
-
Fix for brand voice
|
|
214
|
-
</button>
|
|
215
|
-
)}
|
|
216
|
-
</div>
|
|
217
|
-
)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async function handleSave() {
|
|
221
|
-
if (!entityType || !entityId) return
|
|
222
|
-
setSaving(true)
|
|
223
|
-
const res = await updateSeoFields(entityType, entityId, form)
|
|
224
|
-
setSaving(false)
|
|
225
|
-
if (res.error) {
|
|
226
|
-
toast.error(res.error)
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
toast.success('SEO fields saved.')
|
|
230
|
-
onOpenChange(false)
|
|
231
|
-
onSaved?.()
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const title = form.metaTitle || record?.title || 'Untitled'
|
|
235
|
-
const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
|
|
236
|
-
const url = record?.url ?? ''
|
|
237
|
-
|
|
238
|
-
function aiButton(field: AiField) {
|
|
239
|
-
return (
|
|
240
|
-
<button
|
|
241
|
-
type="button"
|
|
242
|
-
onClick={() => handleAiGenerate(field)}
|
|
243
|
-
disabled={aiBusy !== null}
|
|
244
|
-
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
245
|
-
>
|
|
246
|
-
<Sparkles
|
|
247
|
-
className={`h-3.5 w-3.5 ${aiBusy === field ? 'animate-pulse' : ''}`}
|
|
248
|
-
aria-hidden
|
|
249
|
-
/>
|
|
250
|
-
{aiBusy === field ? 'Generating…' : 'AI Generate'}
|
|
251
|
-
</button>
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return (
|
|
256
|
-
<Drawer
|
|
257
|
-
open={open}
|
|
258
|
-
onOpenChange={onOpenChange}
|
|
259
|
-
title="Edit SEO"
|
|
260
|
-
description={record?.title}
|
|
261
|
-
footer={
|
|
262
|
-
<>
|
|
263
|
-
<button className={btnSecondary} onClick={() => onOpenChange(false)} disabled={saving}>
|
|
264
|
-
Cancel
|
|
265
|
-
</button>
|
|
266
|
-
<button className={btnPrimary} onClick={handleSave} disabled={saving || loading}>
|
|
267
|
-
{saving ? 'Saving…' : 'Save changes'}
|
|
268
|
-
</button>
|
|
269
|
-
</>
|
|
270
|
-
}
|
|
271
|
-
>
|
|
272
|
-
{loading ? (
|
|
273
|
-
<SeoLoading />
|
|
274
|
-
) : error ? (
|
|
275
|
-
<SeoErrorState message={error} />
|
|
276
|
-
) : record ? (
|
|
277
|
-
<div className="space-y-6">
|
|
278
|
-
{/* SERP preview */}
|
|
279
|
-
<div className="border-border rounded-lg border p-4">
|
|
280
|
-
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
|
281
|
-
Google preview
|
|
282
|
-
</p>
|
|
283
|
-
<p className="text-success truncate text-sm">{url}</p>
|
|
284
|
-
<p className="text-primary text-lg leading-snug">{title}</p>
|
|
285
|
-
<p className="text-muted-foreground line-clamp-2 text-sm">{desc}</p>
|
|
286
|
-
</div>
|
|
287
|
-
|
|
288
|
-
<Field
|
|
289
|
-
label="Meta title"
|
|
290
|
-
hint={
|
|
291
|
-
<span className="flex items-center gap-2">
|
|
292
|
-
<CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
|
|
293
|
-
{aiButton('metaTitle')}
|
|
294
|
-
</span>
|
|
295
|
-
}
|
|
296
|
-
>
|
|
297
|
-
<input
|
|
298
|
-
className={inputCls}
|
|
299
|
-
value={form.metaTitle ?? ''}
|
|
300
|
-
onChange={(e) => update({ metaTitle: e.target.value })}
|
|
301
|
-
placeholder="Page title for search results"
|
|
302
|
-
/>
|
|
303
|
-
</Field>
|
|
304
|
-
|
|
305
|
-
<Field
|
|
306
|
-
label="Meta description"
|
|
307
|
-
hint={
|
|
308
|
-
<span className="flex items-center gap-2">
|
|
309
|
-
<CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
|
|
310
|
-
{aiButton('metaDescription')}
|
|
311
|
-
</span>
|
|
312
|
-
}
|
|
313
|
-
>
|
|
314
|
-
<textarea
|
|
315
|
-
className={`${inputCls} min-h-20 resize-y`}
|
|
316
|
-
value={form.metaDescription ?? ''}
|
|
317
|
-
onChange={(e) => update({ metaDescription: e.target.value })}
|
|
318
|
-
placeholder="Short summary shown under the title in search results"
|
|
319
|
-
/>
|
|
320
|
-
{alignmentRow('metaDescription')}
|
|
321
|
-
</Field>
|
|
322
|
-
|
|
323
|
-
<Field label="Focus keyword" hint={aiButton('focusKeyword')}>
|
|
324
|
-
<input
|
|
325
|
-
className={inputCls}
|
|
326
|
-
value={form.focusKeyword ?? ''}
|
|
327
|
-
onChange={(e) => update({ focusKeyword: e.target.value })}
|
|
328
|
-
placeholder="Primary keyword this page targets"
|
|
329
|
-
/>
|
|
330
|
-
</Field>
|
|
331
|
-
|
|
332
|
-
<Field
|
|
333
|
-
label="Key takeaway"
|
|
334
|
-
hint={
|
|
335
|
-
<span className="flex items-center gap-2">
|
|
336
|
-
<CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
|
|
337
|
-
{aiButton('keyTakeaway')}
|
|
338
|
-
</span>
|
|
339
|
-
}
|
|
340
|
-
>
|
|
341
|
-
<textarea
|
|
342
|
-
className={`${inputCls} min-h-16 resize-y`}
|
|
343
|
-
value={form.keyTakeaway ?? ''}
|
|
344
|
-
onChange={(e) => update({ keyTakeaway: e.target.value })}
|
|
345
|
-
placeholder="One or two sentences AI answer engines can quote — what is this page about?"
|
|
346
|
-
/>
|
|
347
|
-
</Field>
|
|
348
|
-
|
|
349
|
-
<Field label="Canonical URL">
|
|
350
|
-
<input
|
|
351
|
-
className={inputCls}
|
|
352
|
-
value={form.canonicalUrl ?? ''}
|
|
353
|
-
onChange={(e) => update({ canonicalUrl: e.target.value })}
|
|
354
|
-
placeholder={url || 'https://example.com/page'}
|
|
355
|
-
/>
|
|
356
|
-
{!form.canonicalUrl?.trim() && url ? (
|
|
357
|
-
<p className="text-muted-foreground text-xs">
|
|
358
|
-
Auto-canonical: {record?.canonicalUrl ?? url}
|
|
359
|
-
</p>
|
|
360
|
-
) : null}
|
|
361
|
-
</Field>
|
|
362
|
-
|
|
363
|
-
<Field label="Schema type">
|
|
364
|
-
<select
|
|
365
|
-
className={inputCls}
|
|
366
|
-
value={form.structuredDataType ?? 'WebPage'}
|
|
367
|
-
onChange={(e) => update({ structuredDataType: e.target.value })}
|
|
368
|
-
>
|
|
369
|
-
{SEO_SCHEMA_TYPES.map((t) => (
|
|
370
|
-
<option key={t} value={t}>
|
|
371
|
-
{t}
|
|
372
|
-
</option>
|
|
373
|
-
))}
|
|
374
|
-
</select>
|
|
375
|
-
</Field>
|
|
376
|
-
|
|
377
|
-
{/* Social preview */}
|
|
378
|
-
<div className="space-y-4">
|
|
379
|
-
<p className="text-foreground text-sm font-medium">Social sharing</p>
|
|
380
|
-
<Field label="Open Graph title" hint={aiButton('ogTitle')}>
|
|
381
|
-
<input
|
|
382
|
-
className={inputCls}
|
|
383
|
-
value={form.ogTitle ?? ''}
|
|
384
|
-
onChange={(e) => update({ ogTitle: e.target.value })}
|
|
385
|
-
placeholder={form.metaTitle || 'Title when shared on social media'}
|
|
386
|
-
/>
|
|
387
|
-
</Field>
|
|
388
|
-
<Field label="Open Graph description" hint={aiButton('ogDescription')}>
|
|
389
|
-
<textarea
|
|
390
|
-
className={`${inputCls} min-h-16 resize-y`}
|
|
391
|
-
value={form.ogDescription ?? ''}
|
|
392
|
-
onChange={(e) => update({ ogDescription: e.target.value })}
|
|
393
|
-
placeholder={form.metaDescription || 'Description when shared on social media'}
|
|
394
|
-
/>
|
|
395
|
-
{alignmentRow('ogDescription')}
|
|
396
|
-
</Field>
|
|
397
|
-
<div className="border-border overflow-hidden rounded-lg border">
|
|
398
|
-
{record.ogImage ? (
|
|
399
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
400
|
-
<img
|
|
401
|
-
src={record.ogImage}
|
|
402
|
-
alt={`Social preview for ${record.title}`}
|
|
403
|
-
className="bg-muted aspect-[1200/630] w-full object-cover"
|
|
404
|
-
/>
|
|
405
|
-
) : (
|
|
406
|
-
<div className="bg-muted text-muted-foreground flex aspect-[1200/630] w-full items-center justify-center text-sm">
|
|
407
|
-
No OG image set
|
|
408
|
-
</div>
|
|
409
|
-
)}
|
|
410
|
-
<div className="p-3">
|
|
411
|
-
<p className="text-muted-foreground truncate text-xs uppercase">
|
|
412
|
-
{url.replace(/^https?:\/\//, '').split('/')[0]}
|
|
413
|
-
</p>
|
|
414
|
-
<p className="text-foreground truncate text-sm font-medium">
|
|
415
|
-
{form.ogTitle || title}
|
|
416
|
-
</p>
|
|
417
|
-
<p className="text-muted-foreground line-clamp-1 text-sm">
|
|
418
|
-
{form.ogDescription || desc}
|
|
419
|
-
</p>
|
|
420
|
-
</div>
|
|
421
|
-
</div>
|
|
422
|
-
</div>
|
|
423
|
-
|
|
424
|
-
{/* Indexing toggles */}
|
|
425
|
-
<div className="border-border space-y-3 rounded-lg border p-4">
|
|
426
|
-
<div className="flex items-center justify-between gap-3">
|
|
427
|
-
<span className="min-w-0">
|
|
428
|
-
<span className="text-foreground block text-sm font-medium">No-index</span>
|
|
429
|
-
<span className="text-muted-foreground block text-sm">
|
|
430
|
-
Hide this page from search engines.
|
|
431
|
-
</span>
|
|
432
|
-
</span>
|
|
433
|
-
<Toggle
|
|
434
|
-
checked={!!form.noindex}
|
|
435
|
-
onCheckedChange={(v) => update({ noindex: v })}
|
|
436
|
-
aria-label="No-index"
|
|
437
|
-
/>
|
|
438
|
-
</div>
|
|
439
|
-
<div className="flex items-center justify-between gap-3">
|
|
440
|
-
<span className="min-w-0">
|
|
441
|
-
<span className="text-foreground block text-sm font-medium">No-follow</span>
|
|
442
|
-
<span className="text-muted-foreground block text-sm">
|
|
443
|
-
Tell crawlers not to follow links on this page.
|
|
444
|
-
</span>
|
|
445
|
-
</span>
|
|
446
|
-
<Toggle
|
|
447
|
-
checked={!!form.nofollow}
|
|
448
|
-
onCheckedChange={(v) => update({ nofollow: v })}
|
|
449
|
-
aria-label="No-follow"
|
|
450
|
-
/>
|
|
451
|
-
</div>
|
|
452
|
-
</div>
|
|
453
|
-
|
|
454
|
-
{/* llms.txt curation */}
|
|
455
|
-
<div className="border-border space-y-4 rounded-lg border p-4">
|
|
456
|
-
<div>
|
|
457
|
-
<p className="text-foreground text-sm font-medium">AI discovery (llms.txt)</p>
|
|
458
|
-
<p className="text-muted-foreground text-sm">
|
|
459
|
-
Controls how this page appears in the curated <code>llms.txt</code> guide that AI
|
|
460
|
-
agents read. It does not affect search ranking.
|
|
461
|
-
</p>
|
|
462
|
-
</div>
|
|
463
|
-
<Field label="Include in llms.txt">
|
|
464
|
-
<select
|
|
465
|
-
className={inputCls}
|
|
466
|
-
value={form.llmsInclude ?? 'AUTO'}
|
|
467
|
-
onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
|
|
468
|
-
>
|
|
469
|
-
<option value="AUTO">Auto — include when eligible</option>
|
|
470
|
-
<option value="ALWAYS">Always — pin it in</option>
|
|
471
|
-
<option value="NEVER">Never — exclude</option>
|
|
472
|
-
</select>
|
|
473
|
-
</Field>
|
|
474
|
-
<Field
|
|
475
|
-
label="Agent description"
|
|
476
|
-
hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
|
|
477
|
-
>
|
|
478
|
-
<textarea
|
|
479
|
-
className={`${inputCls} min-h-16 resize-y`}
|
|
480
|
-
value={form.llmsDescription ?? ''}
|
|
481
|
-
onChange={(e) => update({ llmsDescription: e.target.value })}
|
|
482
|
-
placeholder={
|
|
483
|
-
form.keyTakeaway ||
|
|
484
|
-
form.metaDescription ||
|
|
485
|
-
'One line for an AI agent — what this page covers and why it matters.'
|
|
486
|
-
}
|
|
487
|
-
/>
|
|
488
|
-
</Field>
|
|
489
|
-
<Field label="Section">
|
|
490
|
-
<input
|
|
491
|
-
className={inputCls}
|
|
492
|
-
value={form.llmsSection ?? ''}
|
|
493
|
-
onChange={(e) => update({ llmsSection: e.target.value })}
|
|
494
|
-
placeholder="Defaults to the content type"
|
|
495
|
-
/>
|
|
496
|
-
</Field>
|
|
497
|
-
<div className="flex items-center justify-between gap-3">
|
|
498
|
-
<span className="min-w-0">
|
|
499
|
-
<span className="text-foreground block text-sm font-medium">Optional</span>
|
|
500
|
-
<span className="text-muted-foreground block text-sm">
|
|
501
|
-
Group under “Optional” — agents skip these first when context is tight.
|
|
502
|
-
</span>
|
|
503
|
-
</span>
|
|
504
|
-
<Toggle
|
|
505
|
-
checked={!!form.llmsOptional}
|
|
506
|
-
onCheckedChange={(v) => update({ llmsOptional: v })}
|
|
507
|
-
aria-label="Optional in llms.txt"
|
|
508
|
-
/>
|
|
509
|
-
</div>
|
|
510
|
-
</div>
|
|
511
|
-
|
|
512
|
-
{/* Score summary */}
|
|
513
|
-
{(() => {
|
|
514
|
-
const meta = gradeMeta(record.seoGrade)
|
|
515
|
-
return (
|
|
516
|
-
<div
|
|
517
|
-
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${meta.bg} ${meta.text}`}
|
|
518
|
-
>
|
|
519
|
-
<Check className="h-4 w-4" aria-hidden />
|
|
520
|
-
Current SEO score: <span className="font-medium">{record.seoScore}</span> (
|
|
521
|
-
{meta.label})
|
|
522
|
-
</div>
|
|
523
|
-
)
|
|
524
|
-
})()}
|
|
525
|
-
</div>
|
|
526
|
-
) : null}
|
|
527
|
-
</Drawer>
|
|
528
|
-
)
|
|
529
|
-
}
|