@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. 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
- }