@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,257 @@
1
+ 'use client'
2
+
3
+ import {
4
+ TrendingUp,
5
+ TrendingDown,
6
+ Minus,
7
+ AlertOctagon,
8
+ AlertTriangle,
9
+ Info,
10
+ Zap,
11
+ BarChart3,
12
+ ArrowRight,
13
+ } from 'lucide-react'
14
+ import type { QuickWin, SeoMetric, SeoOverview } from '../../lib/seo-service.js'
15
+ import {
16
+ SectionCard,
17
+ SeoScoreBar,
18
+ SeoLoading,
19
+ SeoEmptyState,
20
+ SeoErrorState,
21
+ gradeMeta,
22
+ btnSecondary,
23
+ } from '../../components/seo/primitives.js'
24
+ import type { SeoResource } from './useSeoResource.js'
25
+
26
+ function TrendIcon({ trend }: { trend?: SeoMetric['trend'] }) {
27
+ if (trend === 'up') return <TrendingUp className="text-success h-4 w-4" aria-hidden />
28
+ if (trend === 'down') return <TrendingDown className="text-destructive h-4 w-4" aria-hidden />
29
+ return <Minus className="text-muted-foreground h-4 w-4" aria-hidden />
30
+ }
31
+
32
+ function impactTone(impact: QuickWin['impact']): 'good' | 'fair' | 'poor' {
33
+ return impact === 'High' ? 'poor' : impact === 'Medium' ? 'fair' : 'good'
34
+ }
35
+
36
+ export function OverviewTab({
37
+ resource,
38
+ onOpenIssue,
39
+ onConnectSearchConsole,
40
+ }: {
41
+ resource: SeoResource<SeoOverview>
42
+ onOpenIssue?: (issueId: string) => void
43
+ onConnectSearchConsole?: () => void
44
+ }) {
45
+ const { data, loading, error, refetch } = resource
46
+
47
+ if (loading) return <SeoLoading label="Loading SEO overview…" />
48
+ if (error) return <SeoErrorState message={error} onRetry={refetch} />
49
+ if (!data) return <SeoEmptyState title="No overview data" />
50
+
51
+ const {
52
+ siteScore,
53
+ metrics,
54
+ issuesSummary,
55
+ quickWins,
56
+ searchPerformance,
57
+ searchConsoleConnected,
58
+ } = data
59
+ const grade = gradeMeta(siteScore.grade)
60
+
61
+ return (
62
+ <div className="space-y-6">
63
+ {/* Site score + metrics */}
64
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
65
+ <SectionCard title="Site SEO Score" className="lg:col-span-1">
66
+ <div className="flex items-center gap-4">
67
+ <div className="flex flex-col">
68
+ <span className="text-foreground text-4xl font-medium tabular-nums">
69
+ {siteScore.score}
70
+ </span>
71
+ <span className={`text-sm font-medium ${grade.text}`}>{grade.label}</span>
72
+ </div>
73
+ {siteScore.delta !== null && (
74
+ <span
75
+ className={`inline-flex items-center gap-1 text-sm ${
76
+ siteScore.delta >= 0 ? 'text-success' : 'text-destructive'
77
+ }`}
78
+ >
79
+ {siteScore.delta >= 0 ? (
80
+ <TrendingUp className="h-4 w-4" aria-hidden />
81
+ ) : (
82
+ <TrendingDown className="h-4 w-4" aria-hidden />
83
+ )}
84
+ {siteScore.delta >= 0 ? '+' : ''}
85
+ {siteScore.delta} pts
86
+ </span>
87
+ )}
88
+ </div>
89
+ <SeoScoreBar score={siteScore.score} className="mt-4" />
90
+ {siteScore.breakdown.length > 0 && (
91
+ <dl className="mt-4 space-y-2">
92
+ {siteScore.breakdown.map((b) => (
93
+ <div key={b.category} className="flex items-center justify-between text-sm">
94
+ <dt className="text-muted-foreground capitalize">{b.category}</dt>
95
+ <dd className="text-foreground tabular-nums">
96
+ {b.score}/{100} <span className="text-muted-foreground">({b.weight}%)</span>
97
+ </dd>
98
+ </div>
99
+ ))}
100
+ </dl>
101
+ )}
102
+ </SectionCard>
103
+
104
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-2">
105
+ {metrics.length === 0 ? (
106
+ <div className="sm:col-span-2">
107
+ <SeoEmptyState
108
+ title="No metrics yet"
109
+ description="Run an audit to populate site metrics."
110
+ />
111
+ </div>
112
+ ) : (
113
+ metrics.map((m) => (
114
+ <div key={m.label} className="border-border bg-card rounded-lg border p-4">
115
+ <p className="text-muted-foreground text-sm">{m.label}</p>
116
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">{m.value}</p>
117
+ <p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
118
+ <TrendIcon trend={m.trend} />
119
+ {m.helper}
120
+ </p>
121
+ </div>
122
+ ))
123
+ )}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Issues found + quick wins */}
128
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
129
+ <SectionCard title="Issues Found">
130
+ <ul className="space-y-3">
131
+ <li className="flex items-center justify-between">
132
+ <span className="text-foreground flex items-center gap-2 text-sm">
133
+ <AlertOctagon className="text-destructive h-4 w-4" aria-hidden /> Critical
134
+ </span>
135
+ <span className="text-foreground font-medium tabular-nums">
136
+ {issuesSummary.critical}
137
+ </span>
138
+ </li>
139
+ <li className="flex items-center justify-between">
140
+ <span className="text-foreground flex items-center gap-2 text-sm">
141
+ <AlertTriangle className="text-warning h-4 w-4" aria-hidden /> Warnings
142
+ </span>
143
+ <span className="text-foreground font-medium tabular-nums">
144
+ {issuesSummary.warning}
145
+ </span>
146
+ </li>
147
+ <li className="flex items-center justify-between">
148
+ <span className="text-foreground flex items-center gap-2 text-sm">
149
+ <Info className="text-muted-foreground h-4 w-4" aria-hidden /> Info
150
+ </span>
151
+ <span className="text-foreground font-medium tabular-nums">{issuesSummary.info}</span>
152
+ </li>
153
+ </ul>
154
+ </SectionCard>
155
+
156
+ <SectionCard title="Quick Wins" description="High-impact fixes ranked by priority">
157
+ {quickWins.length === 0 ? (
158
+ <SeoEmptyState
159
+ icon={<Zap size={24} />}
160
+ title="No quick wins"
161
+ description="Great — no high-impact issues are waiting."
162
+ />
163
+ ) : (
164
+ <ul className="divide-border divide-y">
165
+ {quickWins.map((w) => {
166
+ const tone = impactTone(w.impact)
167
+ const toneCls =
168
+ tone === 'poor'
169
+ ? 'bg-destructive/10 text-destructive'
170
+ : tone === 'fair'
171
+ ? 'bg-warning/10 text-warning'
172
+ : 'bg-success/10 text-success'
173
+ return (
174
+ <li key={w.id} className="flex items-start justify-between gap-3 py-3 first:pt-0">
175
+ <div className="min-w-0">
176
+ <p className="text-foreground text-sm font-medium">{w.title}</p>
177
+ <p className="text-muted-foreground mt-0.5 text-sm">{w.explanation}</p>
178
+ </div>
179
+ <div className="flex shrink-0 items-center gap-2">
180
+ <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${toneCls}`}>
181
+ {w.impact}
182
+ </span>
183
+ {w.issueId && onOpenIssue && (
184
+ <button
185
+ onClick={() => onOpenIssue(w.issueId as string)}
186
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
187
+ >
188
+ Fix <ArrowRight className="h-3.5 w-3.5" aria-hidden />
189
+ </button>
190
+ )}
191
+ </div>
192
+ </li>
193
+ )
194
+ })}
195
+ </ul>
196
+ )}
197
+ </SectionCard>
198
+ </div>
199
+
200
+ {/* Search performance */}
201
+ <SectionCard title="Search Performance" description="Top pages by impressions (last 28 days)">
202
+ {!searchConsoleConnected ? (
203
+ <SeoEmptyState
204
+ icon={<BarChart3 size={24} />}
205
+ title="Search Console not connected"
206
+ description="Connect Google Search Console to see real impressions, clicks, CTR, and average position."
207
+ action={
208
+ onConnectSearchConsole ? (
209
+ <button className={btnSecondary} onClick={onConnectSearchConsole}>
210
+ Connect Search Console
211
+ </button>
212
+ ) : undefined
213
+ }
214
+ />
215
+ ) : searchPerformance.length === 0 ? (
216
+ <SeoEmptyState title="No search performance data" />
217
+ ) : (
218
+ <div className="overflow-x-auto">
219
+ <table className="w-full text-sm" aria-label="Search performance">
220
+ <thead>
221
+ <tr className="text-muted-foreground border-border border-b text-left">
222
+ <th className="py-2 pr-4 font-medium">Page</th>
223
+ <th className="py-2 pr-4 text-right font-medium">Impressions</th>
224
+ <th className="py-2 pr-4 text-right font-medium">Clicks</th>
225
+ <th className="py-2 pr-4 text-right font-medium">CTR</th>
226
+ <th className="py-2 text-right font-medium">Avg. Position</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody className="divide-border divide-y">
230
+ {searchPerformance.map((row) => (
231
+ <tr key={row.url}>
232
+ <td className="py-2 pr-4">
233
+ <span className="text-foreground block font-medium">{row.title}</span>
234
+ <span className="text-muted-foreground block truncate">{row.url}</span>
235
+ </td>
236
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
237
+ {row.impressions.toLocaleString()}
238
+ </td>
239
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
240
+ {row.clicks.toLocaleString()}
241
+ </td>
242
+ <td className="text-foreground py-2 pr-4 text-right tabular-nums">
243
+ {(row.ctr * 100).toFixed(1)}%
244
+ </td>
245
+ <td className="text-foreground py-2 text-right tabular-nums">
246
+ {row.averagePosition.toFixed(1)}
247
+ </td>
248
+ </tr>
249
+ ))}
250
+ </tbody>
251
+ </table>
252
+ </div>
253
+ )}
254
+ </SectionCard>
255
+ </div>
256
+ )
257
+ }
@@ -0,0 +1,398 @@
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 url = URL.createObjectURL(blob)
242
+ const link = document.createElement('a')
243
+ link.href = url
244
+ link.download = 'redirects.csv'
245
+ // Firefox requires the anchor to be in the document for programmatic clicks.
246
+ document.body.appendChild(link)
247
+ link.click()
248
+ document.body.removeChild(link)
249
+ // Defer revocation so the browser has a chance to start the download —
250
+ // revoking synchronously can cancel it before it begins.
251
+ setTimeout(() => URL.revokeObjectURL(url), 0)
252
+ }
253
+
254
+ async function handleImportFile(file: File) {
255
+ const csv = await file.text()
256
+ const res = await importRedirectsCsv(csv)
257
+ if (res.error) return toast.error(res.error)
258
+ if (res.errors && res.errors.length > 0) {
259
+ toast.error(`Imported ${res.imported ?? 0}, ${res.errors.length} row(s) skipped.`)
260
+ } else {
261
+ toast.success(`Imported ${res.imported ?? 0} redirect(s).`)
262
+ }
263
+ refetchAll()
264
+ }
265
+
266
+ return (
267
+ <div className="space-y-4">
268
+ <OverviewCards onRefetchKey={refetchKey} />
269
+
270
+ <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
271
+ <Ai404RecoveryCard onApplied={refetchAll} />
272
+ <ChainMapCard onFlattened={refetchAll} />
273
+ </div>
274
+
275
+ <SectionCard
276
+ title="Redirect Rules"
277
+ action={
278
+ <div className="flex flex-wrap gap-2">
279
+ <input
280
+ ref={fileRef}
281
+ type="file"
282
+ accept=".csv,text/csv"
283
+ className="sr-only"
284
+ aria-hidden
285
+ tabIndex={-1}
286
+ onChange={(e) => {
287
+ const f = e.target.files?.[0]
288
+ if (f) handleImportFile(f)
289
+ e.target.value = ''
290
+ }}
291
+ />
292
+ <button className={btnSecondary} onClick={() => fileRef.current?.click()}>
293
+ <Upload className="h-4 w-4" aria-hidden /> Import
294
+ </button>
295
+ <button className={btnSecondary} onClick={handleExport}>
296
+ <Download className="h-4 w-4" aria-hidden /> Export
297
+ </button>
298
+ <button className={btnPrimary} onClick={() => setEditor({ rule: null })}>
299
+ <Plus className="h-4 w-4" aria-hidden /> New redirect
300
+ </button>
301
+ </div>
302
+ }
303
+ >
304
+ {loading ? (
305
+ <SeoLoading label="Loading redirects…" />
306
+ ) : error ? (
307
+ <SeoErrorState message={error} onRetry={refetch} />
308
+ ) : (data ?? []).length === 0 ? (
309
+ <SeoEmptyState
310
+ icon={<Link2 size={24} />}
311
+ title="No redirects yet"
312
+ description="Create a redirect or import a CSV to get started."
313
+ action={
314
+ <button className={btnPrimary} onClick={() => setEditor({ rule: null })}>
315
+ <Plus className="h-4 w-4" aria-hidden /> New redirect
316
+ </button>
317
+ }
318
+ />
319
+ ) : (
320
+ <div className="overflow-x-auto">
321
+ <table className="w-full text-sm" aria-label="Redirect rules">
322
+ <thead>
323
+ <tr className="text-muted-foreground border-border border-b text-left">
324
+ <th className="py-2 pr-4 font-medium">Source</th>
325
+ <th className="py-2 pr-4 font-medium">Destination</th>
326
+ <th className="py-2 pr-4 font-medium">Type</th>
327
+ <th className="py-2 pr-4 text-right font-medium">Hits</th>
328
+ <th className="py-2 pr-4 font-medium">Status</th>
329
+ <th className="py-2 text-right font-medium">
330
+ <span className="sr-only">Actions</span>
331
+ </th>
332
+ </tr>
333
+ </thead>
334
+ <tbody className="divide-border divide-y">
335
+ {data!.map((r) => (
336
+ <tr key={r.id} className="hover:bg-accent/40">
337
+ <td className="text-foreground py-2.5 pr-4 font-mono">{r.fromPath}</td>
338
+ <td className="text-muted-foreground py-2.5 pr-4 font-mono">
339
+ {r.type === 410 ? <span className="italic">— (gone)</span> : r.toPath}
340
+ </td>
341
+ <td className="text-foreground py-2.5 pr-4 tabular-nums">{r.type}</td>
342
+ <td className="text-foreground py-2.5 pr-4 text-right tabular-nums">
343
+ {r.hits}
344
+ </td>
345
+ <td className="py-2.5 pr-4">
346
+ <span className="flex flex-wrap gap-1.5">
347
+ <SeoStatusBadge
348
+ label={r.enabled ? 'Enabled' : 'Disabled'}
349
+ tone={r.enabled ? 'good' : 'neutral'}
350
+ />
351
+ {r.stale && <SeoStatusBadge label="Stale" tone="fair" />}
352
+ </span>
353
+ </td>
354
+ <td className="py-2.5 text-right">
355
+ <div className="flex justify-end gap-1">
356
+ <button
357
+ className={btnGhostIcon}
358
+ onClick={() => setEditor({ rule: r })}
359
+ aria-label={`Edit redirect ${r.fromPath}`}
360
+ >
361
+ <Pencil className="h-4 w-4" aria-hidden />
362
+ </button>
363
+ <button
364
+ className={btnGhostIcon}
365
+ onClick={() => setConfirmDelete(r)}
366
+ aria-label={`Delete redirect ${r.fromPath}`}
367
+ >
368
+ <Trash2 className="h-4 w-4" aria-hidden />
369
+ </button>
370
+ </div>
371
+ </td>
372
+ </tr>
373
+ ))}
374
+ </tbody>
375
+ </table>
376
+ </div>
377
+ )}
378
+ </SectionCard>
379
+
380
+ <RedirectEditorDrawer
381
+ open={editor !== null}
382
+ onOpenChange={(o) => !o && setEditor(null)}
383
+ rule={editor?.rule ?? null}
384
+ onSaved={refetchAll}
385
+ />
386
+
387
+ <ConfirmDialog
388
+ open={confirmDelete !== null}
389
+ onClose={() => setConfirmDelete(null)}
390
+ onConfirm={() => confirmDelete && handleDelete(confirmDelete)}
391
+ title="Delete redirect"
392
+ description={`Delete the redirect from "${confirmDelete?.fromPath}"? This cannot be undone.`}
393
+ confirmLabel="Delete"
394
+ destructive
395
+ />
396
+ </div>
397
+ )
398
+ }