@actuate-media/cms-admin 0.19.0 → 0.21.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 (129) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +13 -9
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  10. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  11. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  13. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  16. package/dist/components/Breadcrumbs.js +4 -1
  17. package/dist/components/Breadcrumbs.js.map +1 -1
  18. package/dist/components/seo/Drawer.d.ts +11 -0
  19. package/dist/components/seo/Drawer.d.ts.map +1 -0
  20. package/dist/components/seo/Drawer.js +17 -0
  21. package/dist/components/seo/Drawer.js.map +1 -0
  22. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  23. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  24. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  25. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  26. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  27. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  28. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  29. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  30. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  31. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  32. package/dist/components/seo/SeoEditorDrawer.js +120 -0
  33. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  34. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  35. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  36. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  37. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  38. package/dist/components/seo/primitives.d.ts +53 -0
  39. package/dist/components/seo/primitives.d.ts.map +1 -0
  40. package/dist/components/seo/primitives.js +101 -0
  41. package/dist/components/seo/primitives.js.map +1 -0
  42. package/dist/layout/Sidebar.d.ts.map +1 -1
  43. package/dist/layout/Sidebar.js +8 -5
  44. package/dist/layout/Sidebar.js.map +1 -1
  45. package/dist/lib/seo-service.d.ts +322 -0
  46. package/dist/lib/seo-service.d.ts.map +1 -0
  47. package/dist/lib/seo-service.js +331 -0
  48. package/dist/lib/seo-service.js.map +1 -0
  49. package/dist/router/index.d.ts.map +1 -1
  50. package/dist/router/index.js +4 -1
  51. package/dist/router/index.js.map +1 -1
  52. package/dist/views/ApiKeys.d.ts +4 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +5 -3
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/Dashboard.js +2 -2
  57. package/dist/views/Dashboard.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  62. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  63. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  64. package/dist/views/Posts/post-type-presets.js +10 -64
  65. package/dist/views/Posts/post-type-presets.js.map +1 -1
  66. package/dist/views/SEO.d.ts.map +1 -1
  67. package/dist/views/SEO.js +89 -132
  68. package/dist/views/SEO.js.map +1 -1
  69. package/dist/views/Settings.d.ts +4 -1
  70. package/dist/views/Settings.d.ts.map +1 -1
  71. package/dist/views/Settings.js +45 -6
  72. package/dist/views/Settings.js.map +1 -1
  73. package/dist/views/Users.d.ts +4 -1
  74. package/dist/views/Users.d.ts.map +1 -1
  75. package/dist/views/Users.js +5 -3
  76. package/dist/views/Users.js.map +1 -1
  77. package/dist/views/seo/AuditTab.d.ts +6 -0
  78. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  79. package/dist/views/seo/AuditTab.js +57 -0
  80. package/dist/views/seo/AuditTab.js.map +1 -0
  81. package/dist/views/seo/ContentTab.d.ts +5 -0
  82. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  83. package/dist/views/seo/ContentTab.js +27 -0
  84. package/dist/views/seo/ContentTab.js.map +1 -0
  85. package/dist/views/seo/OverviewTab.d.ts +8 -0
  86. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  87. package/dist/views/seo/OverviewTab.js +35 -0
  88. package/dist/views/seo/OverviewTab.js.map +1 -0
  89. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  90. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  91. package/dist/views/seo/RedirectsTab.js +130 -0
  92. package/dist/views/seo/RedirectsTab.js.map +1 -0
  93. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  94. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  95. package/dist/views/seo/TechnicalTab.js +105 -0
  96. package/dist/views/seo/TechnicalTab.js.map +1 -0
  97. package/dist/views/seo/useSeoResource.d.ts +8 -0
  98. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  99. package/dist/views/seo/useSeoResource.js +55 -0
  100. package/dist/views/seo/useSeoResource.js.map +1 -0
  101. package/package.json +2 -2
  102. package/src/AdminRoot.tsx +13 -9
  103. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  104. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  105. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  106. package/src/components/Breadcrumbs.tsx +4 -1
  107. package/src/components/seo/Drawer.tsx +65 -0
  108. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  109. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  110. package/src/components/seo/SeoEditorDrawer.tsx +359 -0
  111. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  112. package/src/components/seo/primitives.tsx +240 -0
  113. package/src/layout/Sidebar.tsx +7 -10
  114. package/src/lib/seo-service.ts +660 -0
  115. package/src/router/index.ts +4 -1
  116. package/src/views/ApiKeys.tsx +10 -5
  117. package/src/views/Dashboard.tsx +2 -2
  118. package/src/views/MediaBrowser.tsx +89 -67
  119. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  120. package/src/views/Posts/post-type-presets.ts +15 -64
  121. package/src/views/SEO.tsx +186 -863
  122. package/src/views/Settings.tsx +93 -40
  123. package/src/views/Users.tsx +10 -4
  124. package/src/views/seo/AuditTab.tsx +229 -0
  125. package/src/views/seo/ContentTab.tsx +164 -0
  126. package/src/views/seo/OverviewTab.tsx +257 -0
  127. package/src/views/seo/RedirectsTab.tsx +398 -0
  128. package/src/views/seo/TechnicalTab.tsx +315 -0
  129. package/src/views/seo/useSeoResource.ts +66 -0
@@ -0,0 +1,359 @@
1
+ 'use client'
2
+
3
+ import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
4
+ import { Sparkles, Check } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ fetchSeoContentRecord,
8
+ updateSeoFields,
9
+ generateSeoField,
10
+ type SeoContentRecord,
11
+ type SeoFieldPayload,
12
+ } from '../../lib/seo-service.js'
13
+ import { Drawer } from './Drawer.js'
14
+ import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from './primitives.js'
15
+
16
+ const META_TITLE_MAX = 60
17
+ const META_DESC_MAX = 160
18
+
19
+ type AiField = 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword'
20
+
21
+ function CharCount({ value, max }: { value: string; max: number }) {
22
+ const over = value.length > max
23
+ return (
24
+ <span className={`text-xs ${over ? 'text-destructive' : 'text-muted-foreground'}`}>
25
+ {value.length}/{max}
26
+ </span>
27
+ )
28
+ }
29
+
30
+ function Field({
31
+ label,
32
+ children,
33
+ hint,
34
+ }: {
35
+ label: string
36
+ children: React.ReactNode
37
+ hint?: React.ReactNode
38
+ }) {
39
+ // Associate the visible label with its control for screen readers. We inject
40
+ // a generated id onto the single child input/textarea (unless it already has
41
+ // one) and point the <label htmlFor> at it.
42
+ const generatedId = useId()
43
+ let control = children
44
+ if (isValidElement(children) && (children.props as { id?: string }).id == null) {
45
+ control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
46
+ }
47
+ const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
48
+
49
+ return (
50
+ <div className="space-y-1.5">
51
+ <div className="flex items-center justify-between">
52
+ <label htmlFor={controlId} className="text-foreground text-sm font-medium">
53
+ {label}
54
+ </label>
55
+ {hint}
56
+ </div>
57
+ {control}
58
+ </div>
59
+ )
60
+ }
61
+
62
+ const inputCls =
63
+ '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'
64
+
65
+ export function SeoEditorDrawer({
66
+ open,
67
+ onOpenChange,
68
+ entityType,
69
+ entityId,
70
+ onSaved,
71
+ }: {
72
+ open: boolean
73
+ onOpenChange: (open: boolean) => void
74
+ entityType: string | null
75
+ entityId: string | null
76
+ onSaved?: () => void
77
+ }) {
78
+ const [record, setRecord] = useState<SeoContentRecord | null>(null)
79
+ const [loading, setLoading] = useState(false)
80
+ const [error, setError] = useState<string | null>(null)
81
+ const [saving, setSaving] = useState(false)
82
+ const [aiBusy, setAiBusy] = useState<AiField | null>(null)
83
+ const [form, setForm] = useState<SeoFieldPayload>({})
84
+
85
+ useEffect(() => {
86
+ if (!open || !entityType || !entityId) return
87
+ let alive = true
88
+ setLoading(true)
89
+ setError(null)
90
+ fetchSeoContentRecord(entityType, entityId)
91
+ .then((rec) => {
92
+ if (!alive) return
93
+ if (!rec) {
94
+ setError('Could not load this record.')
95
+ return
96
+ }
97
+ setRecord(rec)
98
+ setForm({
99
+ metaTitle: rec.metaTitle ?? '',
100
+ metaDescription: rec.metaDescription ?? '',
101
+ canonicalUrl: rec.canonicalUrl ?? '',
102
+ focusKeyword: rec.focusKeyword ?? '',
103
+ ogTitle: rec.ogTitle ?? '',
104
+ ogDescription: rec.ogDescription ?? '',
105
+ noindex: rec.noindex,
106
+ nofollow: rec.nofollow,
107
+ })
108
+ })
109
+ .catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
110
+ .finally(() => alive && setLoading(false))
111
+ return () => {
112
+ alive = false
113
+ }
114
+ }, [open, entityType, entityId])
115
+
116
+ const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
117
+
118
+ async function handleAiGenerate(field: AiField) {
119
+ if (!entityType || !entityId) return
120
+ setAiBusy(field)
121
+ try {
122
+ const result = await generateSeoField(field, { entityType, entityId })
123
+ if (result.unavailable) {
124
+ toast.error('AI is not configured. Add an AI provider to enable suggestions.')
125
+ return
126
+ }
127
+ if (result.text) {
128
+ update({ [field]: result.text } as Partial<SeoFieldPayload>)
129
+ toast.success('AI suggestion applied — review before saving.')
130
+ } else {
131
+ toast.error('No suggestion was returned.')
132
+ }
133
+ } catch {
134
+ toast.error('AI request failed.')
135
+ } finally {
136
+ setAiBusy(null)
137
+ }
138
+ }
139
+
140
+ async function handleSave() {
141
+ if (!entityType || !entityId) return
142
+ setSaving(true)
143
+ const res = await updateSeoFields(entityType, entityId, form)
144
+ setSaving(false)
145
+ if (res.error) {
146
+ toast.error(res.error)
147
+ return
148
+ }
149
+ toast.success('SEO fields saved.')
150
+ onOpenChange(false)
151
+ onSaved?.()
152
+ }
153
+
154
+ const title = form.metaTitle || record?.title || 'Untitled'
155
+ const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
156
+ const url = record?.url ?? ''
157
+
158
+ function aiButton(field: AiField) {
159
+ return (
160
+ <button
161
+ type="button"
162
+ onClick={() => handleAiGenerate(field)}
163
+ disabled={aiBusy !== null}
164
+ 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"
165
+ >
166
+ <Sparkles
167
+ className={`h-3.5 w-3.5 ${aiBusy === field ? 'animate-pulse' : ''}`}
168
+ aria-hidden
169
+ />
170
+ {aiBusy === field ? 'Generating…' : 'AI Generate'}
171
+ </button>
172
+ )
173
+ }
174
+
175
+ return (
176
+ <Drawer
177
+ open={open}
178
+ onOpenChange={onOpenChange}
179
+ title="Edit SEO"
180
+ description={record?.title}
181
+ footer={
182
+ <>
183
+ <button className={btnSecondary} onClick={() => onOpenChange(false)} disabled={saving}>
184
+ Cancel
185
+ </button>
186
+ <button className={btnPrimary} onClick={handleSave} disabled={saving || loading}>
187
+ {saving ? 'Saving…' : 'Save changes'}
188
+ </button>
189
+ </>
190
+ }
191
+ >
192
+ {loading ? (
193
+ <SeoLoading />
194
+ ) : error ? (
195
+ <SeoErrorState message={error} />
196
+ ) : record ? (
197
+ <div className="space-y-6">
198
+ {/* SERP preview */}
199
+ <div className="border-border rounded-lg border p-4">
200
+ <p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
201
+ Google preview
202
+ </p>
203
+ <p className="text-success truncate text-sm">{url}</p>
204
+ <p className="text-primary text-lg leading-snug">{title}</p>
205
+ <p className="text-muted-foreground line-clamp-2 text-sm">{desc}</p>
206
+ </div>
207
+
208
+ <Field
209
+ label="Meta title"
210
+ hint={
211
+ <span className="flex items-center gap-2">
212
+ <CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
213
+ {aiButton('metaTitle')}
214
+ </span>
215
+ }
216
+ >
217
+ <input
218
+ className={inputCls}
219
+ value={form.metaTitle ?? ''}
220
+ onChange={(e) => update({ metaTitle: e.target.value })}
221
+ placeholder="Page title for search results"
222
+ />
223
+ </Field>
224
+
225
+ <Field
226
+ label="Meta description"
227
+ hint={
228
+ <span className="flex items-center gap-2">
229
+ <CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
230
+ {aiButton('metaDescription')}
231
+ </span>
232
+ }
233
+ >
234
+ <textarea
235
+ className={`${inputCls} min-h-20 resize-y`}
236
+ value={form.metaDescription ?? ''}
237
+ onChange={(e) => update({ metaDescription: e.target.value })}
238
+ placeholder="Short summary shown under the title in search results"
239
+ />
240
+ </Field>
241
+
242
+ <Field label="Focus keyword" hint={aiButton('focusKeyword')}>
243
+ <input
244
+ className={inputCls}
245
+ value={form.focusKeyword ?? ''}
246
+ onChange={(e) => update({ focusKeyword: e.target.value })}
247
+ placeholder="Primary keyword this page targets"
248
+ />
249
+ </Field>
250
+
251
+ <Field label="Canonical URL">
252
+ <input
253
+ className={inputCls}
254
+ value={form.canonicalUrl ?? ''}
255
+ onChange={(e) => update({ canonicalUrl: e.target.value })}
256
+ placeholder={url || 'https://example.com/page'}
257
+ />
258
+ </Field>
259
+
260
+ {/* Social preview */}
261
+ <div className="space-y-4">
262
+ <p className="text-foreground text-sm font-medium">Social sharing</p>
263
+ <Field label="Open Graph title" hint={aiButton('ogTitle')}>
264
+ <input
265
+ className={inputCls}
266
+ value={form.ogTitle ?? ''}
267
+ onChange={(e) => update({ ogTitle: e.target.value })}
268
+ placeholder={form.metaTitle || 'Title when shared on social media'}
269
+ />
270
+ </Field>
271
+ <Field label="Open Graph description" hint={aiButton('ogDescription')}>
272
+ <textarea
273
+ className={`${inputCls} min-h-16 resize-y`}
274
+ value={form.ogDescription ?? ''}
275
+ onChange={(e) => update({ ogDescription: e.target.value })}
276
+ placeholder={form.metaDescription || 'Description when shared on social media'}
277
+ />
278
+ </Field>
279
+ <div className="border-border overflow-hidden rounded-lg border">
280
+ {record.ogImage ? (
281
+ // eslint-disable-next-line @next/next/no-img-element
282
+ <img
283
+ src={record.ogImage}
284
+ alt={`Social preview for ${record.title}`}
285
+ className="bg-muted aspect-[1200/630] w-full object-cover"
286
+ />
287
+ ) : (
288
+ <div className="bg-muted text-muted-foreground flex aspect-[1200/630] w-full items-center justify-center text-sm">
289
+ No OG image set
290
+ </div>
291
+ )}
292
+ <div className="p-3">
293
+ <p className="text-muted-foreground truncate text-xs uppercase">
294
+ {url.replace(/^https?:\/\//, '').split('/')[0]}
295
+ </p>
296
+ <p className="text-foreground truncate text-sm font-medium">
297
+ {form.ogTitle || title}
298
+ </p>
299
+ <p className="text-muted-foreground line-clamp-1 text-sm">
300
+ {form.ogDescription || desc}
301
+ </p>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ {/* Indexing toggles */}
307
+ <div className="border-border space-y-3 rounded-lg border p-4">
308
+ <label className="flex items-center justify-between gap-3">
309
+ <span className="min-w-0">
310
+ <span className="text-foreground block text-sm font-medium">No-index</span>
311
+ <span className="text-muted-foreground block text-sm">
312
+ Hide this page from search engines.
313
+ </span>
314
+ </span>
315
+ <input
316
+ type="checkbox"
317
+ role="switch"
318
+ aria-checked={!!form.noindex}
319
+ checked={!!form.noindex}
320
+ onChange={(e) => update({ noindex: e.target.checked })}
321
+ className="accent-primary h-4 w-4"
322
+ />
323
+ </label>
324
+ <label className="flex items-center justify-between gap-3">
325
+ <span className="min-w-0">
326
+ <span className="text-foreground block text-sm font-medium">No-follow</span>
327
+ <span className="text-muted-foreground block text-sm">
328
+ Tell crawlers not to follow links on this page.
329
+ </span>
330
+ </span>
331
+ <input
332
+ type="checkbox"
333
+ role="switch"
334
+ aria-checked={!!form.nofollow}
335
+ checked={!!form.nofollow}
336
+ onChange={(e) => update({ nofollow: e.target.checked })}
337
+ className="accent-primary h-4 w-4"
338
+ />
339
+ </label>
340
+ </div>
341
+
342
+ {/* Score summary */}
343
+ {(() => {
344
+ const meta = gradeMeta(record.seoGrade)
345
+ return (
346
+ <div
347
+ className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${meta.bg} ${meta.text}`}
348
+ >
349
+ <Check className="h-4 w-4" aria-hidden />
350
+ Current SEO score: <span className="font-medium">{record.seoScore}</span> (
351
+ {meta.label})
352
+ </div>
353
+ )
354
+ })()}
355
+ </div>
356
+ ) : null}
357
+ </Drawer>
358
+ )
359
+ }
@@ -0,0 +1,244 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Sparkles, ArrowRight, ExternalLink, Image as ImageIcon, Gauge } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ fetchSeoIssue,
8
+ resolveSeoIssue,
9
+ ignoreSeoIssue,
10
+ reopenSeoIssue,
11
+ explainSeoIssue,
12
+ type SeoIssue,
13
+ } from '../../lib/seo-service.js'
14
+ import { Drawer } from './Drawer.js'
15
+ import { SeoLoading, SeoErrorState, severityMeta, btnPrimary, btnSecondary } from './primitives.js'
16
+
17
+ /** Map a fix action to a destination route within the admin. */
18
+ function fixRoute(issue: SeoIssue): { label: string; path: string; icon: React.ReactNode } | null {
19
+ const ent = issue.entityType
20
+ const id = issue.entityId
21
+ switch (issue.fixActionType) {
22
+ case 'edit-metadata':
23
+ case 'edit-canonical':
24
+ case 'edit-og':
25
+ case 'edit-schema':
26
+ if (ent && id) {
27
+ return {
28
+ label: 'Edit content SEO',
29
+ path: `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`,
30
+ icon: <ArrowRight className="h-4 w-4" aria-hidden />,
31
+ }
32
+ }
33
+ return null
34
+ case 'fix-broken-link':
35
+ case 'create-redirect':
36
+ return {
37
+ label: 'Open redirects',
38
+ path: '/seo?tab=redirects',
39
+ icon: <ArrowRight className="h-4 w-4" aria-hidden />,
40
+ }
41
+ case 'optimize-media':
42
+ return {
43
+ label: 'Open Media Center',
44
+ path: '/media',
45
+ icon: <ImageIcon className="h-4 w-4" aria-hidden />,
46
+ }
47
+ case 'improve-speed':
48
+ return {
49
+ label: 'Technical settings',
50
+ path: '/seo?tab=technical',
51
+ icon: <Gauge className="h-4 w-4" aria-hidden />,
52
+ }
53
+ default:
54
+ return null
55
+ }
56
+ }
57
+
58
+ export function SeoIssueFixDrawer({
59
+ open,
60
+ onOpenChange,
61
+ issueId,
62
+ onChanged,
63
+ onNavigate,
64
+ }: {
65
+ open: boolean
66
+ onOpenChange: (open: boolean) => void
67
+ issueId: string | null
68
+ onChanged?: () => void
69
+ onNavigate?: (path: string) => void
70
+ }) {
71
+ const [issue, setIssue] = useState<SeoIssue | null>(null)
72
+ const [loading, setLoading] = useState(false)
73
+ const [error, setError] = useState<string | null>(null)
74
+ const [busy, setBusy] = useState(false)
75
+ const [explanation, setExplanation] = useState<string | null>(null)
76
+ const [explaining, setExplaining] = useState(false)
77
+
78
+ useEffect(() => {
79
+ if (!open || !issueId) return
80
+ let alive = true
81
+ setLoading(true)
82
+ setError(null)
83
+ setExplanation(null)
84
+ fetchSeoIssue(issueId)
85
+ .then((i) => {
86
+ if (!alive) return
87
+ if (!i) setError('Issue not found.')
88
+ else setIssue(i)
89
+ })
90
+ .catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
91
+ .finally(() => alive && setLoading(false))
92
+ return () => {
93
+ alive = false
94
+ }
95
+ }, [open, issueId])
96
+
97
+ async function mutate(fn: (id: string) => Promise<{ error?: string }>, successMsg: string) {
98
+ if (!issue) return
99
+ setBusy(true)
100
+ const res = await fn(issue.id)
101
+ setBusy(false)
102
+ if (res.error) return toast.error(res.error)
103
+ toast.success(successMsg)
104
+ onOpenChange(false)
105
+ onChanged?.()
106
+ }
107
+
108
+ async function handleExplain() {
109
+ if (!issue) return
110
+ setExplaining(true)
111
+ const res = await explainSeoIssue(issue.id)
112
+ setExplaining(false)
113
+ if (res.unavailable) {
114
+ toast.error('AI is not configured.')
115
+ return
116
+ }
117
+ setExplanation(res.text ?? 'No explanation available.')
118
+ }
119
+
120
+ const route = issue ? fixRoute(issue) : null
121
+
122
+ return (
123
+ <Drawer
124
+ open={open}
125
+ onOpenChange={onOpenChange}
126
+ title="Issue detail"
127
+ width="max-w-lg"
128
+ footer={
129
+ issue ? (
130
+ <>
131
+ {issue.status === 'ignored' ? (
132
+ <button
133
+ className={btnSecondary}
134
+ onClick={() => mutate(reopenSeoIssue, 'Issue reopened.')}
135
+ disabled={busy}
136
+ >
137
+ Reopen
138
+ </button>
139
+ ) : (
140
+ <button
141
+ className={btnSecondary}
142
+ onClick={() => mutate(ignoreSeoIssue, 'Issue ignored.')}
143
+ disabled={busy}
144
+ >
145
+ Ignore
146
+ </button>
147
+ )}
148
+ {route && onNavigate ? (
149
+ <button
150
+ className={btnPrimary}
151
+ onClick={() => {
152
+ onNavigate(route.path)
153
+ onOpenChange(false)
154
+ }}
155
+ >
156
+ {route.icon}
157
+ {route.label}
158
+ </button>
159
+ ) : (
160
+ <button
161
+ className={btnPrimary}
162
+ onClick={() => mutate(resolveSeoIssue, 'Issue resolved.')}
163
+ disabled={busy}
164
+ >
165
+ Mark resolved
166
+ </button>
167
+ )}
168
+ </>
169
+ ) : null
170
+ }
171
+ >
172
+ {loading ? (
173
+ <SeoLoading />
174
+ ) : error ? (
175
+ <SeoErrorState message={error} />
176
+ ) : issue ? (
177
+ <div className="space-y-4">
178
+ {(() => {
179
+ const meta = severityMeta(issue.severity)
180
+ return (
181
+ <span
182
+ className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.bg} ${meta.text}`}
183
+ >
184
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
185
+ {meta.label} · {issue.category}
186
+ </span>
187
+ )
188
+ })()}
189
+
190
+ <div>
191
+ <h3 className="text-foreground text-base font-medium">{issue.title}</h3>
192
+ {issue.entityTitle && (
193
+ <p className="text-muted-foreground mt-0.5 text-sm">{issue.entityTitle}</p>
194
+ )}
195
+ </div>
196
+
197
+ {issue.url && (
198
+ <a
199
+ href={issue.url}
200
+ target="_blank"
201
+ rel="noopener noreferrer"
202
+ className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
203
+ >
204
+ {issue.url}
205
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
206
+ </a>
207
+ )}
208
+
209
+ {issue.description && <p className="text-foreground text-sm">{issue.description}</p>}
210
+
211
+ {issue.impact && (
212
+ <div className="border-border rounded-md border p-3">
213
+ <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
214
+ Impact
215
+ </p>
216
+ <p className="text-foreground mt-1 text-sm">{issue.impact}</p>
217
+ </div>
218
+ )}
219
+
220
+ {issue.recommendation && (
221
+ <div className="border-border rounded-md border p-3">
222
+ <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
223
+ Recommendation
224
+ </p>
225
+ <p className="text-foreground mt-1 text-sm">{issue.recommendation}</p>
226
+ </div>
227
+ )}
228
+
229
+ <div className="border-border rounded-md border p-3">
230
+ <button
231
+ onClick={handleExplain}
232
+ disabled={explaining}
233
+ className="text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
234
+ >
235
+ <Sparkles className={`h-4 w-4 ${explaining ? 'animate-pulse' : ''}`} aria-hidden />
236
+ {explaining ? 'Asking AI…' : 'Explain with AI'}
237
+ </button>
238
+ {explanation && <p className="text-foreground mt-2 text-sm">{explanation}</p>}
239
+ </div>
240
+ </div>
241
+ ) : null}
242
+ </Drawer>
243
+ )
244
+ }