@actuate-media/cms-admin 0.19.0 → 0.20.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 (94) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +9 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/seo/Drawer.d.ts +11 -0
  6. package/dist/components/seo/Drawer.d.ts.map +1 -0
  7. package/dist/components/seo/Drawer.js +13 -0
  8. package/dist/components/seo/Drawer.js.map +1 -0
  9. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  10. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  11. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  12. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  13. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  14. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  15. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  16. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  17. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  18. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  19. package/dist/components/seo/SeoEditorDrawer.js +111 -0
  20. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  21. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  22. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  23. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  24. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  25. package/dist/components/seo/primitives.d.ts +53 -0
  26. package/dist/components/seo/primitives.d.ts.map +1 -0
  27. package/dist/components/seo/primitives.js +100 -0
  28. package/dist/components/seo/primitives.js.map +1 -0
  29. package/dist/layout/Sidebar.d.ts.map +1 -1
  30. package/dist/layout/Sidebar.js +3 -0
  31. package/dist/layout/Sidebar.js.map +1 -1
  32. package/dist/lib/seo-service.d.ts +322 -0
  33. package/dist/lib/seo-service.d.ts.map +1 -0
  34. package/dist/lib/seo-service.js +295 -0
  35. package/dist/lib/seo-service.js.map +1 -0
  36. package/dist/router/index.d.ts.map +1 -1
  37. package/dist/router/index.js +4 -1
  38. package/dist/router/index.js.map +1 -1
  39. package/dist/views/MediaBrowser.d.ts.map +1 -1
  40. package/dist/views/MediaBrowser.js +36 -25
  41. package/dist/views/MediaBrowser.js.map +1 -1
  42. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  43. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  44. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  45. package/dist/views/Posts/post-type-presets.js +10 -64
  46. package/dist/views/Posts/post-type-presets.js.map +1 -1
  47. package/dist/views/SEO.d.ts.map +1 -1
  48. package/dist/views/SEO.js +89 -132
  49. package/dist/views/SEO.js.map +1 -1
  50. package/dist/views/seo/AuditTab.d.ts +6 -0
  51. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  52. package/dist/views/seo/AuditTab.js +57 -0
  53. package/dist/views/seo/AuditTab.js.map +1 -0
  54. package/dist/views/seo/ContentTab.d.ts +5 -0
  55. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  56. package/dist/views/seo/ContentTab.js +27 -0
  57. package/dist/views/seo/ContentTab.js.map +1 -0
  58. package/dist/views/seo/OverviewTab.d.ts +8 -0
  59. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  60. package/dist/views/seo/OverviewTab.js +35 -0
  61. package/dist/views/seo/OverviewTab.js.map +1 -0
  62. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  63. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  64. package/dist/views/seo/RedirectsTab.js +124 -0
  65. package/dist/views/seo/RedirectsTab.js.map +1 -0
  66. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  67. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  68. package/dist/views/seo/TechnicalTab.js +102 -0
  69. package/dist/views/seo/TechnicalTab.js.map +1 -0
  70. package/dist/views/seo/useSeoResource.d.ts +8 -0
  71. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  72. package/dist/views/seo/useSeoResource.js +42 -0
  73. package/dist/views/seo/useSeoResource.js.map +1 -0
  74. package/package.json +2 -2
  75. package/src/AdminRoot.tsx +9 -5
  76. package/src/components/seo/Drawer.tsx +61 -0
  77. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  78. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  79. package/src/components/seo/SeoEditorDrawer.tsx +347 -0
  80. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  81. package/src/components/seo/primitives.tsx +237 -0
  82. package/src/layout/Sidebar.tsx +3 -0
  83. package/src/lib/seo-service.ts +623 -0
  84. package/src/router/index.ts +4 -1
  85. package/src/views/MediaBrowser.tsx +89 -67
  86. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  87. package/src/views/Posts/post-type-presets.ts +15 -64
  88. package/src/views/SEO.tsx +186 -863
  89. package/src/views/seo/AuditTab.tsx +229 -0
  90. package/src/views/seo/ContentTab.tsx +164 -0
  91. package/src/views/seo/OverviewTab.tsx +257 -0
  92. package/src/views/seo/RedirectsTab.tsx +392 -0
  93. package/src/views/seo/TechnicalTab.tsx +303 -0
  94. package/src/views/seo/useSeoResource.ts +52 -0
@@ -0,0 +1,392 @@
1
+ 'use client'
2
+
3
+ import { useRef, useState } from 'react'
4
+ import {
5
+ Plus,
6
+ Pencil,
7
+ Trash2,
8
+ Upload,
9
+ Download,
10
+ Sparkles,
11
+ ArrowRight,
12
+ GitBranch,
13
+ Check,
14
+ X,
15
+ Link2,
16
+ } from 'lucide-react'
17
+ import { toast } from 'sonner'
18
+ import {
19
+ fetchRedirectOverview,
20
+ fetchRedirectRules,
21
+ fetchRedirectChains,
22
+ fetchRedirectSuggestions,
23
+ deleteRedirectRule,
24
+ flattenRedirectChain,
25
+ generate404RecoverySuggestions,
26
+ acceptRedirectSuggestion,
27
+ dismissRedirectSuggestion,
28
+ exportRedirectsCsv,
29
+ importRedirectsCsv,
30
+ type RedirectRule,
31
+ } from '../../lib/seo-service.js'
32
+ import {
33
+ SectionCard,
34
+ SeoLoading,
35
+ SeoEmptyState,
36
+ SeoErrorState,
37
+ SeoStatusBadge,
38
+ btnPrimary,
39
+ btnSecondary,
40
+ btnGhostIcon,
41
+ } from '../../components/seo/primitives.js'
42
+ import { RedirectEditorDrawer } from '../../components/seo/RedirectEditorDrawer.js'
43
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
44
+ import { useSeoResource } from './useSeoResource.js'
45
+
46
+ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
47
+ const { data, loading, error, refetch } = useSeoResource(fetchRedirectOverview, [onRefetchKey])
48
+ if (loading) return <SeoLoading label="Loading redirect overview…" />
49
+ if (error) return <SeoErrorState message={error} onRetry={refetch} />
50
+ if (!data) return null
51
+ const cards = [
52
+ { label: 'Active redirects', value: data.activeRedirects },
53
+ { label: 'Stale redirects', value: data.staleRedirects },
54
+ { label: '404s tracked', value: data.notFoundCount },
55
+ { label: 'Chains & loops', value: data.chainsAndLoops },
56
+ ]
57
+ return (
58
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
59
+ {cards.map((c) => (
60
+ <div key={c.label} className="border-border bg-card rounded-lg border p-4">
61
+ <p className="text-muted-foreground text-sm">{c.label}</p>
62
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">{c.value}</p>
63
+ </div>
64
+ ))}
65
+ </div>
66
+ )
67
+ }
68
+
69
+ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
70
+ const { data, loading, error, refetch } = useSeoResource(fetchRedirectSuggestions, [])
71
+ const [generating, setGenerating] = useState(false)
72
+
73
+ async function handleGenerate() {
74
+ setGenerating(true)
75
+ const res = await generate404RecoverySuggestions()
76
+ setGenerating(false)
77
+ if (res.error) {
78
+ toast.error(res.error)
79
+ return
80
+ }
81
+ toast.success('Generated 404 recovery suggestions.')
82
+ refetch()
83
+ }
84
+
85
+ async function handleAccept(id: string) {
86
+ const res = await acceptRedirectSuggestion(id)
87
+ if (res.error) return toast.error(res.error)
88
+ toast.success('Redirect created from suggestion.')
89
+ refetch()
90
+ onApplied()
91
+ }
92
+
93
+ async function handleDismiss(id: string) {
94
+ const res = await dismissRedirectSuggestion(id)
95
+ if (res.error) return toast.error(res.error)
96
+ refetch()
97
+ }
98
+
99
+ return (
100
+ <SectionCard
101
+ title="AI 404 Recovery"
102
+ description="Suggested redirects for paths returning 404s"
103
+ action={
104
+ <button className={btnSecondary} onClick={handleGenerate} disabled={generating}>
105
+ <Sparkles className={`h-4 w-4 ${generating ? 'animate-pulse' : ''}`} aria-hidden />
106
+ {generating ? 'Analyzing…' : 'Generate suggestions'}
107
+ </button>
108
+ }
109
+ >
110
+ {loading ? (
111
+ <SeoLoading />
112
+ ) : error ? (
113
+ <SeoErrorState message={error} onRetry={refetch} />
114
+ ) : (data ?? []).length === 0 ? (
115
+ <SeoEmptyState
116
+ icon={<Sparkles size={24} />}
117
+ title="No suggestions"
118
+ description="Generate suggestions to recover traffic from broken URLs."
119
+ />
120
+ ) : (
121
+ <ul className="divide-border divide-y">
122
+ {data!.map((s) => (
123
+ <li key={s.id} className="flex items-center justify-between gap-3 py-3 first:pt-0">
124
+ <div className="min-w-0">
125
+ <p className="text-foreground flex items-center gap-2 text-sm">
126
+ <span className="truncate font-medium">{s.fromPath}</span>
127
+ <ArrowRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" aria-hidden />
128
+ <span className="text-muted-foreground truncate">{s.toPath}</span>
129
+ </p>
130
+ {s.reason && <p className="text-muted-foreground mt-0.5 text-sm">{s.reason}</p>}
131
+ </div>
132
+ <div className="flex shrink-0 items-center gap-2">
133
+ <SeoStatusBadge
134
+ label={`${Math.round(s.confidence * 100)}%`}
135
+ tone={s.confidence >= 0.7 ? 'good' : s.confidence >= 0.4 ? 'fair' : 'poor'}
136
+ />
137
+ <button
138
+ onClick={() => handleAccept(s.id)}
139
+ className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none"
140
+ aria-label={`Accept redirect from ${s.fromPath}`}
141
+ >
142
+ <Check className="h-4 w-4" aria-hidden />
143
+ </button>
144
+ <button
145
+ onClick={() => handleDismiss(s.id)}
146
+ className="text-muted-foreground hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none"
147
+ aria-label={`Dismiss suggestion for ${s.fromPath}`}
148
+ >
149
+ <X className="h-4 w-4" aria-hidden />
150
+ </button>
151
+ </div>
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ </SectionCard>
157
+ )
158
+ }
159
+
160
+ function ChainMapCard({ onFlattened }: { onFlattened: () => void }) {
161
+ const { data, loading, error, refetch } = useSeoResource(fetchRedirectChains, [])
162
+
163
+ async function handleFlatten(id: string) {
164
+ const res = await flattenRedirectChain(id)
165
+ if (res.error) return toast.error(res.error)
166
+ toast.success('Chain flattened.')
167
+ refetch()
168
+ onFlattened()
169
+ }
170
+
171
+ if (loading) return null
172
+ if (error) return null
173
+ if (!data || data.length === 0) return null
174
+
175
+ return (
176
+ <SectionCard
177
+ title="Redirect Chains & Loops"
178
+ description="Multi-hop redirects slow crawlers and lose link equity"
179
+ >
180
+ <ul className="space-y-3">
181
+ {data.map((chain) => (
182
+ <li
183
+ key={chain.id}
184
+ className="border-border flex items-center justify-between gap-3 rounded-md border p-3"
185
+ >
186
+ <div className="min-w-0">
187
+ <div className="mb-1 flex items-center gap-2">
188
+ <GitBranch className="text-muted-foreground h-4 w-4" aria-hidden />
189
+ <SeoStatusBadge
190
+ label={chain.kind === 'loop' ? 'Loop' : 'Chain'}
191
+ tone={chain.kind === 'loop' ? 'critical' : 'fair'}
192
+ />
193
+ </div>
194
+ <p className="text-foreground flex flex-wrap items-center gap-1 text-sm">
195
+ {chain.hops.map((hop, i) => (
196
+ <span key={i} className="flex items-center gap-1">
197
+ <span className="truncate">{hop}</span>
198
+ {i < chain.hops.length - 1 && (
199
+ <ArrowRight className="text-muted-foreground h-3 w-3" aria-hidden />
200
+ )}
201
+ </span>
202
+ ))}
203
+ </p>
204
+ </div>
205
+ {chain.kind === 'chain' && (
206
+ <button className={btnSecondary} onClick={() => handleFlatten(chain.id)}>
207
+ Flatten
208
+ </button>
209
+ )}
210
+ </li>
211
+ ))}
212
+ </ul>
213
+ </SectionCard>
214
+ )
215
+ }
216
+
217
+ export function RedirectsTab() {
218
+ const [refetchKey, setRefetchKey] = useState(0)
219
+ const { data, loading, error, refetch } = useSeoResource(fetchRedirectRules, [refetchKey])
220
+ const [editor, setEditor] = useState<{ rule: RedirectRule | null } | null>(null)
221
+ const [confirmDelete, setConfirmDelete] = useState<RedirectRule | null>(null)
222
+ const fileRef = useRef<HTMLInputElement>(null)
223
+
224
+ const bump = () => setRefetchKey((k) => k + 1)
225
+ const refetchAll = () => {
226
+ refetch()
227
+ bump()
228
+ }
229
+
230
+ async function handleDelete(rule: RedirectRule) {
231
+ const res = await deleteRedirectRule(rule.id)
232
+ if (res.error) return toast.error(res.error)
233
+ toast.success('Redirect deleted.')
234
+ refetchAll()
235
+ }
236
+
237
+ async function handleExport() {
238
+ const res = await exportRedirectsCsv()
239
+ if (res.error || !res.csv) return toast.error(res.error ?? 'Export failed.')
240
+ const blob = new Blob([res.csv], { type: 'text/csv' })
241
+ const link = document.createElement('a')
242
+ link.href = URL.createObjectURL(blob)
243
+ link.download = 'redirects.csv'
244
+ link.click()
245
+ URL.revokeObjectURL(link.href)
246
+ }
247
+
248
+ async function handleImportFile(file: File) {
249
+ const csv = await file.text()
250
+ const res = await importRedirectsCsv(csv)
251
+ if (res.error) return toast.error(res.error)
252
+ if (res.errors && res.errors.length > 0) {
253
+ toast.error(`Imported ${res.imported ?? 0}, ${res.errors.length} row(s) skipped.`)
254
+ } else {
255
+ toast.success(`Imported ${res.imported ?? 0} redirect(s).`)
256
+ }
257
+ refetchAll()
258
+ }
259
+
260
+ return (
261
+ <div className="space-y-4">
262
+ <OverviewCards onRefetchKey={refetchKey} />
263
+
264
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
265
+ <Ai404RecoveryCard onApplied={refetchAll} />
266
+ <ChainMapCard onFlattened={refetchAll} />
267
+ </div>
268
+
269
+ <SectionCard
270
+ title="Redirect Rules"
271
+ action={
272
+ <div className="flex flex-wrap gap-2">
273
+ <input
274
+ ref={fileRef}
275
+ type="file"
276
+ accept=".csv,text/csv"
277
+ className="sr-only"
278
+ aria-hidden
279
+ tabIndex={-1}
280
+ onChange={(e) => {
281
+ const f = e.target.files?.[0]
282
+ if (f) handleImportFile(f)
283
+ e.target.value = ''
284
+ }}
285
+ />
286
+ <button className={btnSecondary} onClick={() => fileRef.current?.click()}>
287
+ <Upload className="h-4 w-4" aria-hidden /> Import
288
+ </button>
289
+ <button className={btnSecondary} onClick={handleExport}>
290
+ <Download className="h-4 w-4" aria-hidden /> Export
291
+ </button>
292
+ <button className={btnPrimary} onClick={() => setEditor({ rule: null })}>
293
+ <Plus className="h-4 w-4" aria-hidden /> New redirect
294
+ </button>
295
+ </div>
296
+ }
297
+ >
298
+ {loading ? (
299
+ <SeoLoading label="Loading redirects…" />
300
+ ) : error ? (
301
+ <SeoErrorState message={error} onRetry={refetch} />
302
+ ) : (data ?? []).length === 0 ? (
303
+ <SeoEmptyState
304
+ icon={<Link2 size={24} />}
305
+ title="No redirects yet"
306
+ description="Create a redirect or import a CSV to get started."
307
+ action={
308
+ <button className={btnPrimary} onClick={() => setEditor({ rule: null })}>
309
+ <Plus className="h-4 w-4" aria-hidden /> New redirect
310
+ </button>
311
+ }
312
+ />
313
+ ) : (
314
+ <div className="overflow-x-auto">
315
+ <table className="w-full text-sm" aria-label="Redirect rules">
316
+ <thead>
317
+ <tr className="text-muted-foreground border-border border-b text-left">
318
+ <th className="py-2 pr-4 font-medium">Source</th>
319
+ <th className="py-2 pr-4 font-medium">Destination</th>
320
+ <th className="py-2 pr-4 font-medium">Type</th>
321
+ <th className="py-2 pr-4 text-right font-medium">Hits</th>
322
+ <th className="py-2 pr-4 font-medium">Status</th>
323
+ <th className="py-2 text-right font-medium">
324
+ <span className="sr-only">Actions</span>
325
+ </th>
326
+ </tr>
327
+ </thead>
328
+ <tbody className="divide-border divide-y">
329
+ {data!.map((r) => (
330
+ <tr key={r.id} className="hover:bg-accent/40">
331
+ <td className="text-foreground py-2.5 pr-4 font-mono">{r.fromPath}</td>
332
+ <td className="text-muted-foreground py-2.5 pr-4 font-mono">
333
+ {r.type === 410 ? <span className="italic">— (gone)</span> : r.toPath}
334
+ </td>
335
+ <td className="text-foreground py-2.5 pr-4 tabular-nums">{r.type}</td>
336
+ <td className="text-foreground py-2.5 pr-4 text-right tabular-nums">
337
+ {r.hits}
338
+ </td>
339
+ <td className="py-2.5 pr-4">
340
+ <span className="flex flex-wrap gap-1.5">
341
+ <SeoStatusBadge
342
+ label={r.enabled ? 'Enabled' : 'Disabled'}
343
+ tone={r.enabled ? 'good' : 'neutral'}
344
+ />
345
+ {r.stale && <SeoStatusBadge label="Stale" tone="fair" />}
346
+ </span>
347
+ </td>
348
+ <td className="py-2.5 text-right">
349
+ <div className="flex justify-end gap-1">
350
+ <button
351
+ className={btnGhostIcon}
352
+ onClick={() => setEditor({ rule: r })}
353
+ aria-label={`Edit redirect ${r.fromPath}`}
354
+ >
355
+ <Pencil className="h-4 w-4" aria-hidden />
356
+ </button>
357
+ <button
358
+ className={btnGhostIcon}
359
+ onClick={() => setConfirmDelete(r)}
360
+ aria-label={`Delete redirect ${r.fromPath}`}
361
+ >
362
+ <Trash2 className="h-4 w-4" aria-hidden />
363
+ </button>
364
+ </div>
365
+ </td>
366
+ </tr>
367
+ ))}
368
+ </tbody>
369
+ </table>
370
+ </div>
371
+ )}
372
+ </SectionCard>
373
+
374
+ <RedirectEditorDrawer
375
+ open={editor !== null}
376
+ onOpenChange={(o) => !o && setEditor(null)}
377
+ rule={editor?.rule ?? null}
378
+ onSaved={refetchAll}
379
+ />
380
+
381
+ <ConfirmDialog
382
+ open={confirmDelete !== null}
383
+ onClose={() => setConfirmDelete(null)}
384
+ onConfirm={() => confirmDelete && handleDelete(confirmDelete)}
385
+ title="Delete redirect"
386
+ description={`Delete the redirect from "${confirmDelete?.fromPath}"? This cannot be undone.`}
387
+ confirmLabel="Delete"
388
+ destructive
389
+ />
390
+ </div>
391
+ )
392
+ }
@@ -0,0 +1,303 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { RefreshCw, Map, FileCode2, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ fetchSitemapStatus,
8
+ regenerateSitemap,
9
+ fetchCrawlSettings,
10
+ updateCrawlSettings,
11
+ fetchRobotsTxt,
12
+ updateRobotsTxt,
13
+ validateRobotsTxt,
14
+ type CrawlSettings,
15
+ } from '../../lib/seo-service.js'
16
+ import {
17
+ SectionCard,
18
+ SeoLoading,
19
+ SeoErrorState,
20
+ btnPrimary,
21
+ btnSecondary,
22
+ } from '../../components/seo/primitives.js'
23
+ import { useSeoResource } from './useSeoResource.js'
24
+
25
+ const CRAWL_TOGGLES: { key: keyof CrawlSettings; label: string; help: string }[] = [
26
+ {
27
+ key: 'indexEntireSite',
28
+ label: 'Index entire site',
29
+ help: 'Allow search engines to index all public pages.',
30
+ },
31
+ {
32
+ key: 'includeImagesInSitemap',
33
+ label: 'Include images in sitemap',
34
+ help: 'Add image entries to the XML sitemap.',
35
+ },
36
+ {
37
+ key: 'includePostsInSitemap',
38
+ label: 'Include posts in sitemap',
39
+ help: 'Add published posts to the sitemap.',
40
+ },
41
+ {
42
+ key: 'noindexPaginatedArchives',
43
+ label: 'No-index paginated archives',
44
+ help: 'Avoid duplicate-content penalties on page 2+.',
45
+ },
46
+ {
47
+ key: 'noindexTagPages',
48
+ label: 'No-index tag pages',
49
+ help: 'Keep thin tag archives out of the index.',
50
+ },
51
+ ]
52
+
53
+ function Toggle({
54
+ checked,
55
+ onChange,
56
+ label,
57
+ help,
58
+ }: {
59
+ checked: boolean
60
+ onChange: (v: boolean) => void
61
+ label: string
62
+ help: string
63
+ }) {
64
+ return (
65
+ <label className="flex items-center justify-between gap-3 py-2.5">
66
+ <span className="min-w-0">
67
+ <span className="text-foreground block text-sm font-medium">{label}</span>
68
+ <span className="text-muted-foreground block text-sm">{help}</span>
69
+ </span>
70
+ <input
71
+ type="checkbox"
72
+ role="switch"
73
+ aria-checked={checked}
74
+ checked={checked}
75
+ onChange={(e) => onChange(e.target.checked)}
76
+ className="accent-primary h-4 w-4 shrink-0"
77
+ />
78
+ </label>
79
+ )
80
+ }
81
+
82
+ function SitemapCard() {
83
+ const { data, loading, error, refetch } = useSeoResource(fetchSitemapStatus, [])
84
+ const [busy, setBusy] = useState(false)
85
+
86
+ async function handleRegenerate() {
87
+ setBusy(true)
88
+ const res = await regenerateSitemap()
89
+ setBusy(false)
90
+ if (res.error) {
91
+ toast.error(res.error)
92
+ return
93
+ }
94
+ toast.success('Sitemap regenerated.')
95
+ refetch()
96
+ }
97
+
98
+ return (
99
+ <SectionCard
100
+ title="Sitemap"
101
+ action={
102
+ <button className={btnSecondary} onClick={handleRegenerate} disabled={busy || loading}>
103
+ <RefreshCw className={`h-4 w-4 ${busy ? 'animate-spin' : ''}`} aria-hidden />
104
+ Regenerate
105
+ </button>
106
+ }
107
+ >
108
+ {loading ? (
109
+ <SeoLoading label="Loading sitemap status…" />
110
+ ) : error ? (
111
+ <SeoErrorState message={error} onRetry={refetch} />
112
+ ) : !data ? (
113
+ <p className="text-muted-foreground text-sm">Sitemap status unavailable.</p>
114
+ ) : (
115
+ <div className="space-y-3">
116
+ <a
117
+ href={data.sitemapUrl}
118
+ target="_blank"
119
+ rel="noopener noreferrer"
120
+ className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
121
+ >
122
+ <Map className="h-4 w-4" aria-hidden />
123
+ {data.sitemapUrl}
124
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
125
+ </a>
126
+ <dl className="grid grid-cols-2 gap-3 text-sm">
127
+ <div>
128
+ <dt className="text-muted-foreground">Pages included</dt>
129
+ <dd className="text-foreground font-medium tabular-nums">{data.pagesIncluded}</dd>
130
+ </div>
131
+ <div>
132
+ <dt className="text-muted-foreground">Pages excluded</dt>
133
+ <dd className="text-foreground font-medium tabular-nums">{data.pagesExcluded}</dd>
134
+ </div>
135
+ <div className="col-span-2">
136
+ <dt className="text-muted-foreground">Last generated</dt>
137
+ <dd className="text-foreground">
138
+ {data.lastGeneratedAt ? new Date(data.lastGeneratedAt).toLocaleString() : 'Never'}
139
+ </dd>
140
+ </div>
141
+ </dl>
142
+ {data.warnings.length > 0 && (
143
+ <ul className="space-y-1">
144
+ {data.warnings.map((w, i) => (
145
+ <li key={i} className="text-warning flex items-center gap-1.5 text-sm">
146
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
147
+ </li>
148
+ ))}
149
+ </ul>
150
+ )}
151
+ </div>
152
+ )}
153
+ </SectionCard>
154
+ )
155
+ }
156
+
157
+ function CrawlCard() {
158
+ const { data, loading, error, refetch } = useSeoResource(fetchCrawlSettings, [])
159
+ const [settings, setSettings] = useState<CrawlSettings | null>(null)
160
+ const [saving, setSaving] = useState(false)
161
+
162
+ useEffect(() => {
163
+ if (data) setSettings(data)
164
+ }, [data])
165
+
166
+ async function save(next: CrawlSettings, changed: keyof CrawlSettings) {
167
+ setSettings(next)
168
+ setSaving(true)
169
+ const res = await updateCrawlSettings({ [changed]: next[changed] })
170
+ setSaving(false)
171
+ if (res.error) {
172
+ toast.error(res.error)
173
+ refetch()
174
+ }
175
+ }
176
+
177
+ return (
178
+ <SectionCard title="Crawl Settings">
179
+ {loading ? (
180
+ <SeoLoading label="Loading crawl settings…" />
181
+ ) : error ? (
182
+ <SeoErrorState message={error} onRetry={refetch} />
183
+ ) : !settings ? (
184
+ <p className="text-muted-foreground text-sm">Crawl settings unavailable.</p>
185
+ ) : (
186
+ <div className="divide-border divide-y" aria-busy={saving}>
187
+ {CRAWL_TOGGLES.map((t) => (
188
+ <Toggle
189
+ key={t.key}
190
+ label={t.label}
191
+ help={t.help}
192
+ checked={settings[t.key]}
193
+ onChange={(v) => save({ ...settings, [t.key]: v }, t.key)}
194
+ />
195
+ ))}
196
+ </div>
197
+ )}
198
+ </SectionCard>
199
+ )
200
+ }
201
+
202
+ function RobotsCard() {
203
+ const { data, loading, error, refetch } = useSeoResource(fetchRobotsTxt, [])
204
+ const [body, setBody] = useState('')
205
+ const [saving, setSaving] = useState(false)
206
+
207
+ useEffect(() => {
208
+ if (data) setBody(data.body)
209
+ }, [data])
210
+
211
+ const validation = validateRobotsTxt(body)
212
+
213
+ async function handleSave() {
214
+ setSaving(true)
215
+ const res = await updateRobotsTxt(body)
216
+ setSaving(false)
217
+ if (res.error) {
218
+ toast.error(res.error)
219
+ return
220
+ }
221
+ toast.success('robots.txt saved.')
222
+ refetch()
223
+ }
224
+
225
+ return (
226
+ <SectionCard
227
+ title="robots.txt"
228
+ description="Controls which crawlers can access your site."
229
+ action={
230
+ <button
231
+ className={btnPrimary}
232
+ onClick={handleSave}
233
+ disabled={saving || loading || !validation.valid}
234
+ >
235
+ {saving ? 'Saving…' : 'Save'}
236
+ </button>
237
+ }
238
+ >
239
+ {loading ? (
240
+ <SeoLoading label="Loading robots.txt…" />
241
+ ) : error ? (
242
+ <SeoErrorState message={error} onRetry={refetch} />
243
+ ) : (
244
+ <div className="space-y-3">
245
+ {validation.blocksEntireSite && (
246
+ <div
247
+ className="border-destructive/30 bg-destructive/10 flex items-start gap-2 rounded-md border p-3"
248
+ role="alert"
249
+ >
250
+ <AlertTriangle className="text-destructive mt-0.5 h-4 w-4 shrink-0" aria-hidden />
251
+ <p className="text-destructive text-sm">
252
+ This configuration blocks your entire site from all search engines. Pages will be
253
+ removed from search results.
254
+ </p>
255
+ </div>
256
+ )}
257
+ <label className="sr-only" htmlFor="robots-editor">
258
+ robots.txt contents
259
+ </label>
260
+ <textarea
261
+ id="robots-editor"
262
+ value={body}
263
+ onChange={(e) => setBody(e.target.value)}
264
+ spellCheck={false}
265
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring min-h-48 w-full rounded-md border p-3 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
266
+ />
267
+ {validation.errors.length > 0 ? (
268
+ <ul className="space-y-1">
269
+ {validation.errors.map((e, i) => (
270
+ <li key={i} className="text-destructive flex items-center gap-1.5 text-sm">
271
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {e}
272
+ </li>
273
+ ))}
274
+ </ul>
275
+ ) : (
276
+ <p className="text-success flex items-center gap-1.5 text-sm">
277
+ <CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Valid robots.txt
278
+ </p>
279
+ )}
280
+ {validation.warnings
281
+ .filter((w) => !validation.blocksEntireSite || !w.includes('blocks the entire site'))
282
+ .map((w, i) => (
283
+ <p key={i} className="text-warning flex items-center gap-1.5 text-sm">
284
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
285
+ </p>
286
+ ))}
287
+ </div>
288
+ )}
289
+ </SectionCard>
290
+ )
291
+ }
292
+
293
+ export function TechnicalTab() {
294
+ return (
295
+ <div className="space-y-4">
296
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
297
+ <SitemapCard />
298
+ <CrawlCard />
299
+ </div>
300
+ <RobotsCard />
301
+ </div>
302
+ )
303
+ }