@actuate-media/cms-admin 0.18.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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +13 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +111 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +100 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +295 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +124 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +102 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +42 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +9 -5
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/components/seo/Drawer.tsx +61 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +347 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +237 -0
- package/src/index.ts +0 -2
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/lib/seo-service.ts +623 -0
- package/src/router/index.ts +4 -1
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +392 -0
- package/src/views/seo/TechnicalTab.tsx +303 -0
- package/src/views/seo/useSeoResource.ts +52 -0
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -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,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
|
+
}
|