@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +13 -9
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
- package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
- package/dist/__tests__/views/use-seo-resource.test.js +56 -0
- package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +4 -1
- package/dist/components/Breadcrumbs.js.map +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 +17 -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 +120 -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 +101 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +8 -5
- 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 +331 -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/ApiKeys.d.ts +4 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +5 -3
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Dashboard.js +2 -2
- package/dist/views/Dashboard.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/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +45 -6
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +5 -3
- package/dist/views/Users.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 +130 -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 +105 -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 +55 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +13 -9
- package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
- package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
- package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
- package/src/components/Breadcrumbs.tsx +4 -1
- package/src/components/seo/Drawer.tsx +65 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +359 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +240 -0
- package/src/layout/Sidebar.tsx +7 -10
- package/src/lib/seo-service.ts +660 -0
- package/src/router/index.ts +4 -1
- package/src/views/ApiKeys.tsx +10 -5
- package/src/views/Dashboard.tsx +2 -2
- 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/Settings.tsx +93 -40
- package/src/views/Users.tsx +10 -4
- 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 +398 -0
- package/src/views/seo/TechnicalTab.tsx +315 -0
- package/src/views/seo/useSeoResource.ts +66 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useState } from 'react'
|
|
4
|
+
import { RefreshCw, Map, AlertTriangle, CheckCircle2, ExternalLink } from 'lucide-react'
|
|
5
|
+
import * as Switch from '@radix-ui/react-switch'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
import {
|
|
8
|
+
fetchSitemapStatus,
|
|
9
|
+
regenerateSitemap,
|
|
10
|
+
fetchCrawlSettings,
|
|
11
|
+
updateCrawlSettings,
|
|
12
|
+
fetchRobotsTxt,
|
|
13
|
+
updateRobotsTxt,
|
|
14
|
+
validateRobotsTxt,
|
|
15
|
+
type CrawlSettings,
|
|
16
|
+
} from '../../lib/seo-service.js'
|
|
17
|
+
import {
|
|
18
|
+
SectionCard,
|
|
19
|
+
SeoLoading,
|
|
20
|
+
SeoErrorState,
|
|
21
|
+
btnPrimary,
|
|
22
|
+
btnSecondary,
|
|
23
|
+
} from '../../components/seo/primitives.js'
|
|
24
|
+
import { useSeoResource } from './useSeoResource.js'
|
|
25
|
+
|
|
26
|
+
const CRAWL_TOGGLES: { key: keyof CrawlSettings; label: string; help: string }[] = [
|
|
27
|
+
{
|
|
28
|
+
key: 'indexEntireSite',
|
|
29
|
+
label: 'Index entire site',
|
|
30
|
+
help: 'Allow search engines to index all public pages.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'includeImagesInSitemap',
|
|
34
|
+
label: 'Include images in sitemap',
|
|
35
|
+
help: 'Add image entries to the XML sitemap.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'includePostsInSitemap',
|
|
39
|
+
label: 'Include posts in sitemap',
|
|
40
|
+
help: 'Add published posts to the sitemap.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'noindexPaginatedArchives',
|
|
44
|
+
label: 'No-index paginated archives',
|
|
45
|
+
help: 'Avoid duplicate-content penalties on page 2+.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: 'noindexTagPages',
|
|
49
|
+
label: 'No-index tag pages',
|
|
50
|
+
help: 'Keep thin tag archives out of the index.',
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
function Toggle({
|
|
55
|
+
checked,
|
|
56
|
+
onChange,
|
|
57
|
+
label,
|
|
58
|
+
help,
|
|
59
|
+
disabled,
|
|
60
|
+
}: {
|
|
61
|
+
checked: boolean
|
|
62
|
+
onChange: (v: boolean) => void
|
|
63
|
+
label: string
|
|
64
|
+
help: string
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
}) {
|
|
67
|
+
const labelId = useId()
|
|
68
|
+
const helpId = useId()
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex items-center justify-between gap-3 py-2.5">
|
|
71
|
+
<span className="min-w-0">
|
|
72
|
+
<span id={labelId} className="text-foreground block text-sm font-medium">
|
|
73
|
+
{label}
|
|
74
|
+
</span>
|
|
75
|
+
<span id={helpId} className="text-muted-foreground block text-sm">
|
|
76
|
+
{help}
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
<Switch.Root
|
|
80
|
+
checked={checked}
|
|
81
|
+
onCheckedChange={onChange}
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
aria-labelledby={labelId}
|
|
84
|
+
aria-describedby={helpId}
|
|
85
|
+
className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
86
|
+
>
|
|
87
|
+
<Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
|
|
88
|
+
</Switch.Root>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function SitemapCard() {
|
|
94
|
+
const { data, loading, error, refetch } = useSeoResource(fetchSitemapStatus, [])
|
|
95
|
+
const [busy, setBusy] = useState(false)
|
|
96
|
+
|
|
97
|
+
async function handleRegenerate() {
|
|
98
|
+
setBusy(true)
|
|
99
|
+
const res = await regenerateSitemap()
|
|
100
|
+
setBusy(false)
|
|
101
|
+
if (res.error) {
|
|
102
|
+
toast.error(res.error)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
toast.success('Sitemap regenerated.')
|
|
106
|
+
refetch()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<SectionCard
|
|
111
|
+
title="Sitemap"
|
|
112
|
+
action={
|
|
113
|
+
<button className={btnSecondary} onClick={handleRegenerate} disabled={busy || loading}>
|
|
114
|
+
<RefreshCw className={`h-4 w-4 ${busy ? 'animate-spin' : ''}`} aria-hidden />
|
|
115
|
+
Regenerate
|
|
116
|
+
</button>
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{loading ? (
|
|
120
|
+
<SeoLoading label="Loading sitemap status…" />
|
|
121
|
+
) : error ? (
|
|
122
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
123
|
+
) : !data ? (
|
|
124
|
+
<p className="text-muted-foreground text-sm">Sitemap status unavailable.</p>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="space-y-3">
|
|
127
|
+
<a
|
|
128
|
+
href={data.sitemapUrl}
|
|
129
|
+
target="_blank"
|
|
130
|
+
rel="noopener noreferrer"
|
|
131
|
+
className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
|
|
132
|
+
>
|
|
133
|
+
<Map className="h-4 w-4" aria-hidden />
|
|
134
|
+
{data.sitemapUrl}
|
|
135
|
+
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
136
|
+
</a>
|
|
137
|
+
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
138
|
+
<div>
|
|
139
|
+
<dt className="text-muted-foreground">Pages included</dt>
|
|
140
|
+
<dd className="text-foreground font-medium tabular-nums">{data.pagesIncluded}</dd>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<dt className="text-muted-foreground">Pages excluded</dt>
|
|
144
|
+
<dd className="text-foreground font-medium tabular-nums">{data.pagesExcluded}</dd>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="col-span-2">
|
|
147
|
+
<dt className="text-muted-foreground">Last generated</dt>
|
|
148
|
+
<dd className="text-foreground">
|
|
149
|
+
{data.lastGeneratedAt ? new Date(data.lastGeneratedAt).toLocaleString() : 'Never'}
|
|
150
|
+
</dd>
|
|
151
|
+
</div>
|
|
152
|
+
</dl>
|
|
153
|
+
{data.warnings.length > 0 && (
|
|
154
|
+
<ul className="space-y-1">
|
|
155
|
+
{data.warnings.map((w, i) => (
|
|
156
|
+
<li key={i} className="text-warning flex items-center gap-1.5 text-sm">
|
|
157
|
+
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</SectionCard>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function CrawlCard() {
|
|
169
|
+
const { data, loading, error, refetch } = useSeoResource(fetchCrawlSettings, [])
|
|
170
|
+
const [settings, setSettings] = useState<CrawlSettings | null>(null)
|
|
171
|
+
const [saving, setSaving] = useState(false)
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (data) setSettings(data)
|
|
175
|
+
}, [data])
|
|
176
|
+
|
|
177
|
+
async function save(next: CrawlSettings, changed: keyof CrawlSettings) {
|
|
178
|
+
setSettings(next)
|
|
179
|
+
setSaving(true)
|
|
180
|
+
const res = await updateCrawlSettings({ [changed]: next[changed] })
|
|
181
|
+
setSaving(false)
|
|
182
|
+
if (res.error) {
|
|
183
|
+
toast.error(res.error)
|
|
184
|
+
refetch()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<SectionCard title="Crawl Settings">
|
|
190
|
+
{loading ? (
|
|
191
|
+
<SeoLoading label="Loading crawl settings…" />
|
|
192
|
+
) : error ? (
|
|
193
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
194
|
+
) : !settings ? (
|
|
195
|
+
<p className="text-muted-foreground text-sm">Crawl settings unavailable.</p>
|
|
196
|
+
) : (
|
|
197
|
+
<div className="divide-border divide-y" aria-busy={saving}>
|
|
198
|
+
{CRAWL_TOGGLES.map((t) => (
|
|
199
|
+
<Toggle
|
|
200
|
+
key={t.key}
|
|
201
|
+
label={t.label}
|
|
202
|
+
help={t.help}
|
|
203
|
+
checked={settings[t.key]}
|
|
204
|
+
disabled={saving}
|
|
205
|
+
onChange={(v) => save({ ...settings, [t.key]: v }, t.key)}
|
|
206
|
+
/>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</SectionCard>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function RobotsCard() {
|
|
215
|
+
const { data, loading, error, refetch } = useSeoResource(fetchRobotsTxt, [])
|
|
216
|
+
const [body, setBody] = useState('')
|
|
217
|
+
const [saving, setSaving] = useState(false)
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (data) setBody(data.body)
|
|
221
|
+
}, [data])
|
|
222
|
+
|
|
223
|
+
const validation = validateRobotsTxt(body)
|
|
224
|
+
|
|
225
|
+
async function handleSave() {
|
|
226
|
+
setSaving(true)
|
|
227
|
+
const res = await updateRobotsTxt(body)
|
|
228
|
+
setSaving(false)
|
|
229
|
+
if (res.error) {
|
|
230
|
+
toast.error(res.error)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
toast.success('robots.txt saved.')
|
|
234
|
+
refetch()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<SectionCard
|
|
239
|
+
title="robots.txt"
|
|
240
|
+
description="Controls which crawlers can access your site."
|
|
241
|
+
action={
|
|
242
|
+
<button
|
|
243
|
+
className={btnPrimary}
|
|
244
|
+
onClick={handleSave}
|
|
245
|
+
disabled={saving || loading || !validation.valid}
|
|
246
|
+
>
|
|
247
|
+
{saving ? 'Saving…' : 'Save'}
|
|
248
|
+
</button>
|
|
249
|
+
}
|
|
250
|
+
>
|
|
251
|
+
{loading ? (
|
|
252
|
+
<SeoLoading label="Loading robots.txt…" />
|
|
253
|
+
) : error ? (
|
|
254
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
255
|
+
) : (
|
|
256
|
+
<div className="space-y-3">
|
|
257
|
+
{validation.blocksEntireSite && (
|
|
258
|
+
<div
|
|
259
|
+
className="border-destructive/30 bg-destructive/10 flex items-start gap-2 rounded-md border p-3"
|
|
260
|
+
role="alert"
|
|
261
|
+
>
|
|
262
|
+
<AlertTriangle className="text-destructive mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
263
|
+
<p className="text-destructive text-sm">
|
|
264
|
+
This configuration blocks your entire site from all search engines. Pages will be
|
|
265
|
+
removed from search results.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
<label className="sr-only" htmlFor="robots-editor">
|
|
270
|
+
robots.txt contents
|
|
271
|
+
</label>
|
|
272
|
+
<textarea
|
|
273
|
+
id="robots-editor"
|
|
274
|
+
value={body}
|
|
275
|
+
onChange={(e) => setBody(e.target.value)}
|
|
276
|
+
spellCheck={false}
|
|
277
|
+
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"
|
|
278
|
+
/>
|
|
279
|
+
{validation.errors.length > 0 ? (
|
|
280
|
+
<ul className="space-y-1">
|
|
281
|
+
{validation.errors.map((e, i) => (
|
|
282
|
+
<li key={i} className="text-destructive flex items-center gap-1.5 text-sm">
|
|
283
|
+
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {e}
|
|
284
|
+
</li>
|
|
285
|
+
))}
|
|
286
|
+
</ul>
|
|
287
|
+
) : (
|
|
288
|
+
<p className="text-success flex items-center gap-1.5 text-sm">
|
|
289
|
+
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Valid robots.txt
|
|
290
|
+
</p>
|
|
291
|
+
)}
|
|
292
|
+
{validation.warnings
|
|
293
|
+
.filter((w) => !validation.blocksEntireSite || !w.includes('blocks the entire site'))
|
|
294
|
+
.map((w, i) => (
|
|
295
|
+
<p key={i} className="text-warning flex items-center gap-1.5 text-sm">
|
|
296
|
+
<AlertTriangle className="h-3.5 w-3.5" aria-hidden /> {w}
|
|
297
|
+
</p>
|
|
298
|
+
))}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</SectionCard>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function TechnicalTab() {
|
|
306
|
+
return (
|
|
307
|
+
<div className="space-y-4">
|
|
308
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
309
|
+
<SitemapCard />
|
|
310
|
+
<CrawlCard />
|
|
311
|
+
</div>
|
|
312
|
+
<RobotsCard />
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small async-resource hook for the SEO tabs: wraps a service fetcher with
|
|
5
|
+
* loading / error / data / refetch and abort-on-unmount safety. Keeps each
|
|
6
|
+
* tab declarative without re-implementing fetch plumbing.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
export interface SeoResource<T> {
|
|
11
|
+
data: T | null
|
|
12
|
+
loading: boolean
|
|
13
|
+
error: string | null
|
|
14
|
+
refetch: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSeoResource<T>(
|
|
18
|
+
fetcher: () => Promise<T>,
|
|
19
|
+
deps: ReadonlyArray<unknown> = [],
|
|
20
|
+
): SeoResource<T> {
|
|
21
|
+
const [data, setData] = useState<T | null>(null)
|
|
22
|
+
const [loading, setLoading] = useState(true)
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
// Tracks whether the component is still mounted (guards setState after
|
|
25
|
+
// unmount). Set once on mount; never toggled by dependency-driven reloads.
|
|
26
|
+
const mountedRef = useRef(true)
|
|
27
|
+
// Monotonic id of the most recently *started* request. A response is only
|
|
28
|
+
// applied when its id still matches — otherwise a slow earlier request that
|
|
29
|
+
// resolves after a faster later one (rapid filter switches) would clobber
|
|
30
|
+
// the newer, correct data.
|
|
31
|
+
const requestIdRef = useRef(0)
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
const stableFetcher = useCallback(fetcher, deps)
|
|
34
|
+
|
|
35
|
+
const load = useCallback(async () => {
|
|
36
|
+
const requestId = ++requestIdRef.current
|
|
37
|
+
const isCurrent = () => mountedRef.current && requestId === requestIdRef.current
|
|
38
|
+
setLoading(true)
|
|
39
|
+
setError(null)
|
|
40
|
+
try {
|
|
41
|
+
const result = await stableFetcher()
|
|
42
|
+
if (isCurrent()) setData(result)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (isCurrent()) {
|
|
45
|
+
setError(err instanceof Error ? err.message : 'Failed to load data')
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
if (isCurrent()) setLoading(false)
|
|
49
|
+
}
|
|
50
|
+
}, [stableFetcher])
|
|
51
|
+
|
|
52
|
+
// Mount/unmount guard only — independent of `load` so a dependency change
|
|
53
|
+
// (which recreates `load`) never flips `mountedRef` to false mid-flight.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
mountedRef.current = true
|
|
56
|
+
return () => {
|
|
57
|
+
mountedRef.current = false
|
|
58
|
+
}
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
load()
|
|
63
|
+
}, [load])
|
|
64
|
+
|
|
65
|
+
return { data, loading, error, refetch: load }
|
|
66
|
+
}
|