@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- 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/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/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/NewPostTypeInstructions.js +2 -2
- 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/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/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/layout/Sidebar.tsx +3 -0
- 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/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- 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
|
@@ -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
|
+
}
|