@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
@@ -0,0 +1,533 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Inline SEO editor pane — design-parity replacement for the old
5
+ * `SeoEditorDrawer` overlay. Rendered inside an `<InspectorPane>` beside the
6
+ * Content SEO list (380px in the handoff). Same fields, validation, AI
7
+ * assists, and `updateSeoFields` save path as the drawer; only the
8
+ * presentation pattern changed.
9
+ */
10
+ import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
11
+ import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
12
+ import { toast } from 'sonner'
13
+ import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
14
+ import {
15
+ fetchSeoContentRecord,
16
+ updateSeoFields,
17
+ generateSeoField,
18
+ SEO_SCHEMA_TYPES,
19
+ type SeoContentRecord,
20
+ type SeoFieldPayload,
21
+ type LlmsInclude,
22
+ } from '../../lib/seo-service.js'
23
+ import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
24
+ import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
25
+ import {
26
+ InspectorPaneBody,
27
+ InspectorPaneFooter,
28
+ InspectorPaneHeader,
29
+ InspectorPaneSection,
30
+ } from '../ui/InspectorPane.js'
31
+ import { Toggle } from '../ui/Toggle.js'
32
+ import { SeoLoading, SeoErrorState, gradeMeta } from './primitives.js'
33
+
34
+ const META_TITLE_MAX = 60
35
+ const META_DESC_MAX = 160
36
+ const KEY_TAKEAWAY_MAX = 240
37
+ const LLMS_DESC_MAX = 160
38
+
39
+ type AiField =
40
+ | 'metaTitle'
41
+ | 'metaDescription'
42
+ | 'ogTitle'
43
+ | 'ogDescription'
44
+ | 'focusKeyword'
45
+ | 'keyTakeaway'
46
+
47
+ function seoBrandChannel(field: AiField): string | undefined {
48
+ if (field === 'metaDescription' || field === 'ogDescription') return 'meta_description'
49
+ return undefined
50
+ }
51
+
52
+ const inputCls =
53
+ 'w-full rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[12.5px] text-[var(--txt)] placeholder:text-[var(--muted)] focus:border-[var(--acc)] focus:outline-none'
54
+
55
+ function CharCount({ value, max }: { value: string; max: number }) {
56
+ const over = value.length > max
57
+ return (
58
+ <span className={`text-[11px] ${over ? 'text-[var(--err)]' : 'text-[var(--muted)]'}`}>
59
+ {value.length}/{max}
60
+ </span>
61
+ )
62
+ }
63
+
64
+ function PaneField({
65
+ label,
66
+ children,
67
+ hint,
68
+ }: {
69
+ label: string
70
+ children: React.ReactNode
71
+ hint?: React.ReactNode
72
+ }) {
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
+ const generatedId = useId()
77
+ let control = children
78
+ if (isValidElement(children) && (children.props as { id?: string }).id == null) {
79
+ control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
80
+ }
81
+ const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
82
+
83
+ return (
84
+ <div className="mb-3 last:mb-0">
85
+ <div className="mb-1 flex items-center justify-between gap-2">
86
+ <label htmlFor={controlId} className="text-[11.5px] font-medium text-[var(--sub)]">
87
+ {label}
88
+ </label>
89
+ {hint}
90
+ </div>
91
+ {control}
92
+ </div>
93
+ )
94
+ }
95
+
96
+ function PaneToggleRow({
97
+ label,
98
+ hint,
99
+ checked,
100
+ onCheckedChange,
101
+ }: {
102
+ label: string
103
+ hint: string
104
+ checked: boolean
105
+ onCheckedChange: (checked: boolean) => void
106
+ }) {
107
+ return (
108
+ <div className="flex items-center justify-between gap-3 py-1.5">
109
+ <div className="min-w-0">
110
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{label}</div>
111
+ <div className="text-[11px] text-[var(--muted)]">{hint}</div>
112
+ </div>
113
+ <Toggle checked={checked} onCheckedChange={onCheckedChange} aria-label={label} />
114
+ </div>
115
+ )
116
+ }
117
+
118
+ export interface SeoEditorPaneProps {
119
+ entityType: string
120
+ entityId: string
121
+ onClose: () => void
122
+ /** Called after a successful save so the list can refetch. */
123
+ onSaved?: () => void
124
+ }
125
+
126
+ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEditorPaneProps) {
127
+ const [record, setRecord] = useState<SeoContentRecord | null>(null)
128
+ const [loading, setLoading] = useState(true)
129
+ const [error, setError] = useState<string | null>(null)
130
+ const [saving, setSaving] = useState(false)
131
+ const [aiBusy, setAiBusy] = useState<AiField | null>(null)
132
+ const [aiFixBusy, setAiFixBusy] = useState<AiField | null>(null)
133
+ const [aiAlignments, setAiAlignments] = useState<
134
+ Partial<Record<AiField, BrandAlignmentScoreResult>>
135
+ >({})
136
+ const [form, setForm] = useState<SeoFieldPayload>({})
137
+
138
+ useEffect(() => {
139
+ let alive = true
140
+ setLoading(true)
141
+ setError(null)
142
+ fetchSeoContentRecord(entityType, entityId)
143
+ .then((rec) => {
144
+ if (!alive) return
145
+ if (!rec) {
146
+ setError('Could not load this record.')
147
+ return
148
+ }
149
+ setRecord(rec)
150
+ setForm({
151
+ metaTitle: rec.metaTitle ?? '',
152
+ metaDescription: rec.metaDescription ?? '',
153
+ canonicalUrl: rec.canonicalUrl ?? '',
154
+ focusKeyword: rec.focusKeyword ?? '',
155
+ keyTakeaway: rec.keyTakeaway ?? '',
156
+ ogTitle: rec.ogTitle ?? '',
157
+ ogDescription: rec.ogDescription ?? '',
158
+ structuredDataType: rec.structuredDataType ?? 'WebPage',
159
+ noindex: rec.noindex,
160
+ nofollow: rec.nofollow,
161
+ llmsInclude: rec.llmsInclude ?? 'AUTO',
162
+ llmsDescription: rec.llmsDescription ?? '',
163
+ llmsSection: rec.llmsSection ?? '',
164
+ llmsOptional: rec.llmsOptional ?? false,
165
+ })
166
+ })
167
+ .catch((e: unknown) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
168
+ .finally(() => alive && setLoading(false))
169
+ return () => {
170
+ alive = false
171
+ }
172
+ }, [entityType, entityId])
173
+
174
+ const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
175
+
176
+ async function handleAiGenerate(field: AiField) {
177
+ setAiBusy(field)
178
+ try {
179
+ const result = await generateSeoField(field, { entityType, entityId })
180
+ if (result.unavailable) {
181
+ toast.error('AI is not configured. Add an AI provider to enable suggestions.')
182
+ return
183
+ }
184
+ if (result.text) {
185
+ update({ [field]: result.text } as Partial<SeoFieldPayload>)
186
+ if (result.brandAlignment) {
187
+ setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
188
+ }
189
+ toast.success('AI suggestion applied — review before saving.')
190
+ } else {
191
+ toast.error('No suggestion was returned.')
192
+ }
193
+ } catch {
194
+ toast.error('AI request failed.')
195
+ } finally {
196
+ setAiBusy(null)
197
+ }
198
+ }
199
+
200
+ async function handleAiFix(field: AiField) {
201
+ const text = (form[field as keyof SeoFieldPayload] as string | undefined) ?? ''
202
+ if (!text.trim()) return
203
+ setAiFixBusy(field)
204
+ try {
205
+ const res = await fixBrandVoiceContent({
206
+ content: text,
207
+ channel: seoBrandChannel(field),
208
+ })
209
+ if (!res.ok || !res.result) {
210
+ toast.error(res.error ?? 'Could not fix copy for brand voice.')
211
+ return
212
+ }
213
+ update({ [field]: res.result.text } as Partial<SeoFieldPayload>)
214
+ setAiAlignments((prev) => ({ ...prev, [field]: res.result!.afterScore }))
215
+ toast.success('Copy adjusted to match brand voice.')
216
+ } finally {
217
+ setAiFixBusy(null)
218
+ }
219
+ }
220
+
221
+ function alignmentRow(field: AiField) {
222
+ const alignment = aiAlignments[field]
223
+ if (!alignment) return null
224
+ const fixing = aiFixBusy === field
225
+ return (
226
+ <div className="mt-2 flex flex-wrap items-center gap-2">
227
+ <BrandVoiceAlignmentBadge alignment={alignment} compact />
228
+ {alignment.score < 85 && (
229
+ <button
230
+ type="button"
231
+ onClick={() => void handleAiFix(field)}
232
+ disabled={fixing || aiBusy !== null}
233
+ className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11.5px] 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"
234
+ >
235
+ {fixing ? (
236
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
237
+ ) : (
238
+ <Wand2 className="h-3.5 w-3.5" aria-hidden />
239
+ )}
240
+ Fix for brand voice
241
+ </button>
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ async function handleSave() {
248
+ setSaving(true)
249
+ const res = await updateSeoFields(entityType, entityId, form)
250
+ setSaving(false)
251
+ if (res.error) {
252
+ toast.error(res.error)
253
+ return
254
+ }
255
+ toast.success('SEO fields saved.')
256
+ onSaved?.()
257
+ }
258
+
259
+ const title = form.metaTitle || record?.title || 'Untitled'
260
+ const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
261
+ const url = record?.url ?? ''
262
+
263
+ function aiButton(field: AiField) {
264
+ return (
265
+ <button
266
+ type="button"
267
+ onClick={() => void handleAiGenerate(field)}
268
+ disabled={aiBusy !== null}
269
+ 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
+ >
271
+ <Sparkles className={`h-3 w-3 ${aiBusy === field ? 'animate-pulse' : ''}`} aria-hidden />
272
+ {aiBusy === field ? 'Generating…' : 'AI Generate'}
273
+ </button>
274
+ )
275
+ }
276
+
277
+ return (
278
+ <>
279
+ <InspectorPaneHeader onClose={onClose} closeLabel="Close SEO editor">
280
+ <div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
281
+ {record?.title ?? 'Edit SEO'}
282
+ </div>
283
+ {url && <div className="mt-0.5 truncate text-[11.5px] text-[var(--muted)]">{url}</div>}
284
+ </InspectorPaneHeader>
285
+
286
+ <InspectorPaneBody>
287
+ {loading ? (
288
+ <div className="px-4 py-3.5">
289
+ <SeoLoading />
290
+ </div>
291
+ ) : error ? (
292
+ <div className="px-4 py-3.5">
293
+ <SeoErrorState message={error} />
294
+ </div>
295
+ ) : record ? (
296
+ <>
297
+ <InspectorPaneSection title="Google Preview">
298
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
299
+ <p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
300
+ <p className="text-[15px] leading-snug text-[var(--acc)]">{title}</p>
301
+ <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
302
+ </div>
303
+ </InspectorPaneSection>
304
+
305
+ <InspectorPaneSection title="Meta Tags">
306
+ <PaneField
307
+ label="Meta title"
308
+ hint={
309
+ <span className="flex items-center gap-2">
310
+ <CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
311
+ {aiButton('metaTitle')}
312
+ </span>
313
+ }
314
+ >
315
+ <input
316
+ className={inputCls}
317
+ value={form.metaTitle ?? ''}
318
+ onChange={(e) => update({ metaTitle: e.target.value })}
319
+ placeholder="Page title for search results"
320
+ />
321
+ </PaneField>
322
+ <PaneField
323
+ label="Meta description"
324
+ hint={
325
+ <span className="flex items-center gap-2">
326
+ <CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
327
+ {aiButton('metaDescription')}
328
+ </span>
329
+ }
330
+ >
331
+ <textarea
332
+ className={`${inputCls} min-h-20 resize-y`}
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>
357
+ }
358
+ >
359
+ <textarea
360
+ className={`${inputCls} min-h-16 resize-y`}
361
+ value={form.keyTakeaway ?? ''}
362
+ onChange={(e) => update({ keyTakeaway: e.target.value })}
363
+ placeholder="One or two sentences AI answer engines can quote — what is this page about?"
364
+ />
365
+ </PaneField>
366
+ </InspectorPaneSection>
367
+
368
+ <InspectorPaneSection title="Open Graph" subtitle="Social sharing">
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"
393
+ />
394
+ ) : (
395
+ <div className="flex aspect-[1200/630] w-full items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--muted)]">
396
+ No OG image set
397
+ </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
+ </div>
410
+ </div>
411
+ </InspectorPaneSection>
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 })}
432
+ >
433
+ {SEO_SCHEMA_TYPES.map((t) => (
434
+ <option key={t} value={t}>
435
+ {t}
436
+ </option>
437
+ ))}
438
+ </select>
439
+ </PaneField>
440
+ <PaneToggleRow
441
+ label="No-index"
442
+ hint="Hide this page from search engines"
443
+ checked={!!form.noindex}
444
+ onCheckedChange={(v) => update({ noindex: v })}
445
+ />
446
+ <PaneToggleRow
447
+ label="No-follow"
448
+ hint="Tell crawlers not to follow links on this page"
449
+ checked={!!form.nofollow}
450
+ onCheckedChange={(v) => update({ nofollow: v })}
451
+ />
452
+ </InspectorPaneSection>
453
+
454
+ <InspectorPaneSection title="AI Discovery" subtitle="llms.txt">
455
+ <p className="mb-3 text-[11px] text-[var(--muted)]">
456
+ Controls how this page appears in the curated <code>llms.txt</code> guide that AI
457
+ agents read. It does not affect search ranking.
458
+ </p>
459
+ <PaneField label="Include in llms.txt">
460
+ <select
461
+ className={inputCls}
462
+ value={form.llmsInclude ?? 'AUTO'}
463
+ onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
464
+ >
465
+ <option value="AUTO">Auto — include when eligible</option>
466
+ <option value="ALWAYS">Always — pin it in</option>
467
+ <option value="NEVER">Never — exclude</option>
468
+ </select>
469
+ </PaneField>
470
+ <PaneField
471
+ label="Agent description"
472
+ hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
473
+ >
474
+ <textarea
475
+ className={`${inputCls} min-h-16 resize-y`}
476
+ value={form.llmsDescription ?? ''}
477
+ onChange={(e) => update({ llmsDescription: e.target.value })}
478
+ placeholder={
479
+ form.keyTakeaway ||
480
+ form.metaDescription ||
481
+ 'One line for an AI agent — what this page covers and why it matters.'
482
+ }
483
+ />
484
+ </PaneField>
485
+ <PaneField label="Section">
486
+ <input
487
+ className={inputCls}
488
+ value={form.llmsSection ?? ''}
489
+ onChange={(e) => update({ llmsSection: e.target.value })}
490
+ placeholder="Defaults to the content type"
491
+ />
492
+ </PaneField>
493
+ <PaneToggleRow
494
+ label="Optional"
495
+ hint="Group under “Optional” — agents skip these first when context is tight"
496
+ checked={!!form.llmsOptional}
497
+ onCheckedChange={(v) => update({ llmsOptional: v })}
498
+ />
499
+ </InspectorPaneSection>
500
+
501
+ <InspectorPaneSection>
502
+ {(() => {
503
+ const meta = gradeMeta(record.seoGrade)
504
+ return (
505
+ <div
506
+ className={`flex items-center gap-2 rounded-[7px] px-3 py-2 text-[12.5px] ${meta.bg} ${meta.text}`}
507
+ >
508
+ <Check className="h-4 w-4 shrink-0" aria-hidden />
509
+ <span>
510
+ Current SEO score: <span className="font-medium">{record.seoScore}</span> (
511
+ {meta.label})
512
+ </span>
513
+ </div>
514
+ )
515
+ })()}
516
+ </InspectorPaneSection>
517
+ </>
518
+ ) : null}
519
+ </InspectorPaneBody>
520
+
521
+ <InspectorPaneFooter>
522
+ <button
523
+ type="button"
524
+ onClick={() => void handleSave()}
525
+ disabled={saving || loading || !!error}
526
+ className="w-full rounded-[7px] bg-[var(--acc)] px-3 py-2 text-[13px] font-semibold text-white 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
+ >
528
+ {saving ? 'Saving…' : 'Save Changes'}
529
+ </button>
530
+ </InspectorPaneFooter>
531
+ </>
532
+ )
533
+ }
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ /** How often the idle checker wakes up to compare against the deadline. */
6
+ const CHECK_INTERVAL_MS = 30_000
7
+
8
+ /**
9
+ * Signs the user out after `timeoutMs` of no user activity, when enabled by
10
+ * the Settings → Security "Idle auto-logout" policy.
11
+ *
12
+ * Deliberately lightweight: activity events only stamp a ref (no state, no
13
+ * re-renders), and a single coarse interval compares the last-activity stamp
14
+ * against the deadline. Returning to a hidden tab re-checks immediately via
15
+ * `visibilitychange`, so a tab left overnight logs out as soon as it's seen
16
+ * (or at the next interval tick) rather than staying signed in.
17
+ *
18
+ * All listeners and the interval are torn down on unmount or when disabled.
19
+ */
20
+ export function useIdleLogout(options: {
21
+ enabled: boolean
22
+ timeoutMs: number
23
+ onIdle: () => void
24
+ }): void {
25
+ const { enabled, timeoutMs } = options
26
+ // Keep the latest callback without re-subscribing listeners on every render.
27
+ const onIdleRef = useRef(options.onIdle)
28
+ onIdleRef.current = options.onIdle
29
+
30
+ useEffect(() => {
31
+ if (!enabled || timeoutMs <= 0 || typeof window === 'undefined') return
32
+
33
+ let lastActivity = Date.now()
34
+ let fired = false
35
+
36
+ const markActivity = () => {
37
+ lastActivity = Date.now()
38
+ }
39
+
40
+ const checkIdle = () => {
41
+ if (fired) return
42
+ if (Date.now() - lastActivity >= timeoutMs) {
43
+ fired = true
44
+ onIdleRef.current()
45
+ }
46
+ }
47
+
48
+ // Passive listeners — activity stamping must never block scrolling/typing.
49
+ const activityEvents = [
50
+ 'pointermove',
51
+ 'pointerdown',
52
+ 'keydown',
53
+ 'scroll',
54
+ 'touchstart',
55
+ ] as const
56
+ for (const event of activityEvents) {
57
+ window.addEventListener(event, markActivity, { passive: true })
58
+ }
59
+
60
+ // A tab becoming visible again is NOT user activity — check whether the
61
+ // deadline passed while it was hidden and log out immediately if so.
62
+ const onVisibility = () => {
63
+ if (document.visibilityState === 'visible') checkIdle()
64
+ }
65
+ document.addEventListener('visibilitychange', onVisibility)
66
+
67
+ const interval = window.setInterval(checkIdle, Math.min(CHECK_INTERVAL_MS, timeoutMs))
68
+
69
+ return () => {
70
+ for (const event of activityEvents) {
71
+ window.removeEventListener(event, markActivity)
72
+ }
73
+ document.removeEventListener('visibilitychange', onVisibility)
74
+ window.clearInterval(interval)
75
+ }
76
+ }, [enabled, timeoutMs])
77
+ }
package/src/lib/api.ts CHANGED
@@ -65,7 +65,7 @@ function appendLocaleParam(endpoint: string): string {
65
65
  export async function cmsApi<T = unknown>(
66
66
  endpoint: string,
67
67
  options: RequestInit = {},
68
- ): Promise<{ data?: T; error?: string; status: number }> {
68
+ ): Promise<{ data?: T; error?: string; code?: string; status: number }> {
69
69
  const url = `${basePath}${appendLocaleParam(endpoint)}`
70
70
  const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
71
71
 
@@ -96,7 +96,13 @@ export async function cmsApi<T = unknown>(
96
96
  const json = await res.json().catch(() => ({}))
97
97
 
98
98
  if (!res.ok) {
99
- return { error: json.error || `Request failed (${res.status})`, status: res.status }
99
+ return {
100
+ error: json.error || `Request failed (${res.status})`,
101
+ // Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK) set by
102
+ // guard responses so callers can branch without parsing the message.
103
+ code: typeof json.code === 'string' ? json.code : undefined,
104
+ status: res.status,
105
+ }
100
106
  }
101
107
 
102
108
  return { data: json.data ?? json, status: res.status }