@actuate-media/cms-admin 0.73.0 → 0.74.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 (161) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/LoginRoot.d.ts.map +1 -1
  6. package/dist/LoginRoot.js +2 -1
  7. package/dist/LoginRoot.js.map +1 -1
  8. package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
  9. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  11. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  12. package/dist/__tests__/views/portal-container.test.js +13 -0
  13. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  14. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  15. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  16. package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
  17. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/CoauthorResultPopover.js +1 -1
  20. package/dist/components/EditorSeoAside.d.ts +20 -0
  21. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  22. package/dist/components/EditorSeoAside.js +30 -0
  23. package/dist/components/EditorSeoAside.js.map +1 -0
  24. package/dist/components/MediaPickerModal.js +1 -1
  25. package/dist/components/SEOConfigPanel.js +1 -1
  26. package/dist/components/SchedulePublishDialog.js +1 -1
  27. package/dist/components/SharePreviewLinkDialog.js +1 -1
  28. package/dist/components/ThemeProvider.d.ts.map +1 -1
  29. package/dist/components/ThemeProvider.js +2 -1
  30. package/dist/components/ThemeProvider.js.map +1 -1
  31. package/dist/components/VersionHistory.js +1 -1
  32. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  33. package/dist/components/seo/SeoEditorPane.js +28 -8
  34. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  35. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  36. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  37. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  38. package/dist/components/seo/primitives.d.ts.map +1 -1
  39. package/dist/components/seo/primitives.js +3 -3
  40. package/dist/components/seo/primitives.js.map +1 -1
  41. package/dist/components/ui/Card.js +5 -5
  42. package/dist/components/ui/Card.js.map +1 -1
  43. package/dist/components/ui/InspectorPane.js +3 -3
  44. package/dist/components/ui/InspectorPane.js.map +1 -1
  45. package/dist/components/ui/PanelSection.d.ts +16 -0
  46. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  47. package/dist/components/ui/PanelSection.js +15 -0
  48. package/dist/components/ui/PanelSection.js.map +1 -0
  49. package/dist/lib/admin-root-element.d.ts +9 -0
  50. package/dist/lib/admin-root-element.d.ts.map +1 -0
  51. package/dist/lib/admin-root-element.js +14 -0
  52. package/dist/lib/admin-root-element.js.map +1 -0
  53. package/dist/lib/portal-container.d.ts.map +1 -1
  54. package/dist/lib/portal-container.js +2 -3
  55. package/dist/lib/portal-container.js.map +1 -1
  56. package/dist/lib/seo-service.d.ts +89 -0
  57. package/dist/lib/seo-service.d.ts.map +1 -1
  58. package/dist/lib/seo-service.js +50 -2
  59. package/dist/lib/seo-service.js.map +1 -1
  60. package/dist/views/ApiKeys.js +2 -2
  61. package/dist/views/Dashboard.d.ts.map +1 -1
  62. package/dist/views/Dashboard.js +12 -14
  63. package/dist/views/Dashboard.js.map +1 -1
  64. package/dist/views/MediaBrowser.d.ts.map +1 -1
  65. package/dist/views/MediaBrowser.js +35 -6
  66. package/dist/views/MediaBrowser.js.map +1 -1
  67. package/dist/views/PageEditor.js +1 -1
  68. package/dist/views/Posts/PostTypeEditor.js +3 -3
  69. package/dist/views/SEO.d.ts.map +1 -1
  70. package/dist/views/SEO.js +4 -2
  71. package/dist/views/SEO.js.map +1 -1
  72. package/dist/views/ScriptTags.js +1 -1
  73. package/dist/views/ScriptTags.js.map +1 -1
  74. package/dist/views/Users.js +1 -1
  75. package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
  76. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  77. package/dist/views/page-editor/EditorTopBar.js +5 -3
  78. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  79. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  80. package/dist/views/page-editor/PageSectionEditor.js +24 -1
  81. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  82. package/dist/views/page-editor/SectionInspector.js +2 -2
  83. package/dist/views/page-editor/SectionsPanel.js +1 -1
  84. package/dist/views/post-editor/PostHeaderPanel.js +1 -1
  85. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostSectionEditor.js +24 -1
  87. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  88. package/dist/views/seo/LinksTab.d.ts +4 -0
  89. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  90. package/dist/views/seo/LinksTab.js +111 -0
  91. package/dist/views/seo/LinksTab.js.map +1 -0
  92. package/dist/views/seo/OverviewTab.js +1 -1
  93. package/dist/views/seo/OverviewTab.js.map +1 -1
  94. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  95. package/dist/views/seo/RedirectsTab.js +39 -6
  96. package/dist/views/seo/RedirectsTab.js.map +1 -1
  97. package/dist/views/seo/TechnicalTab.js +2 -2
  98. package/dist/views/settings/AISettingsTab.js +1 -1
  99. package/dist/views/settings/AiFeaturesCard.js +1 -1
  100. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  101. package/dist/views/settings/AppearanceTab.js +1 -1
  102. package/dist/views/settings/BrandVoiceCard.js +1 -1
  103. package/dist/views/settings/EmailSettingsTab.js +2 -2
  104. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  105. package/dist/views/settings/SecurityStatusCard.js +1 -1
  106. package/dist/views/settings/SeoSettingsTab.js +4 -4
  107. package/dist/views/settings/UpdatesTab.js +2 -2
  108. package/dist/views/settings/components.js +3 -3
  109. package/dist/views/settings/components.js.map +1 -1
  110. package/package.json +2 -2
  111. package/src/AdminRoot.tsx +3 -2
  112. package/src/LoginRoot.tsx +2 -1
  113. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  114. package/src/__tests__/views/portal-container.test.tsx +14 -0
  115. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  116. package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
  117. package/src/components/CoauthorResultPopover.tsx +1 -1
  118. package/src/components/EditorSeoAside.tsx +149 -0
  119. package/src/components/MediaPickerModal.tsx +1 -1
  120. package/src/components/SEOConfigPanel.tsx +2 -2
  121. package/src/components/SchedulePublishDialog.tsx +1 -1
  122. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  123. package/src/components/ThemeProvider.tsx +3 -1
  124. package/src/components/VersionHistory.tsx +1 -1
  125. package/src/components/seo/SeoEditorPane.tsx +41 -12
  126. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  127. package/src/components/seo/primitives.tsx +7 -3
  128. package/src/components/ui/Card.tsx +5 -5
  129. package/src/components/ui/InspectorPane.tsx +3 -3
  130. package/src/components/ui/PanelSection.tsx +53 -0
  131. package/src/lib/admin-root-element.ts +15 -0
  132. package/src/lib/portal-container.ts +3 -2
  133. package/src/lib/seo-service.ts +143 -2
  134. package/src/views/ApiKeys.tsx +2 -2
  135. package/src/views/Dashboard.tsx +40 -53
  136. package/src/views/MediaBrowser.tsx +62 -12
  137. package/src/views/PageEditor.tsx +1 -1
  138. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  139. package/src/views/SEO.tsx +6 -0
  140. package/src/views/ScriptTags.tsx +2 -2
  141. package/src/views/Users.tsx +1 -1
  142. package/src/views/page-editor/EditorTopBar.tsx +24 -0
  143. package/src/views/page-editor/PageSectionEditor.tsx +43 -3
  144. package/src/views/page-editor/SectionInspector.tsx +2 -2
  145. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  146. package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
  147. package/src/views/post-editor/PostSectionEditor.tsx +43 -3
  148. package/src/views/seo/LinksTab.tsx +462 -0
  149. package/src/views/seo/OverviewTab.tsx +1 -1
  150. package/src/views/seo/RedirectsTab.tsx +97 -4
  151. package/src/views/seo/TechnicalTab.tsx +2 -2
  152. package/src/views/settings/AISettingsTab.tsx +3 -3
  153. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  154. package/src/views/settings/AppearanceTab.tsx +1 -1
  155. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  156. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  157. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  158. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  159. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  160. package/src/views/settings/UpdatesTab.tsx +3 -3
  161. package/src/views/settings/components.tsx +3 -3
@@ -0,0 +1,462 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Links tab — internal / external link tracking with AI-powered suggestions
5
+ * (orphan recovery, under-linked coverage). Backed by `/seo/links/*` APIs.
6
+ */
7
+ import { useCallback, useMemo, useState } from 'react'
8
+ import {
9
+ Link2,
10
+ Sparkles,
11
+ AlertTriangle,
12
+ CheckCircle2,
13
+ Loader2,
14
+ RefreshCw,
15
+ ExternalLink,
16
+ Bot,
17
+ } from 'lucide-react'
18
+ import { toast } from 'sonner'
19
+ import {
20
+ fetchLinkGraphSnapshot,
21
+ fetchLinkHealthIssues,
22
+ fetchLinkSuggestions,
23
+ runLinkAiIndex,
24
+ type LinkGraphEntityRef,
25
+ type LinkSuggestion,
26
+ } from '../../lib/seo-service.js'
27
+ import { editPathForDoc } from '../../lib/collection-routes.js'
28
+ import {
29
+ SectionCard,
30
+ SeoLoading,
31
+ SeoErrorState,
32
+ SeoEmptyState,
33
+ btnPrimary,
34
+ btnSecondary,
35
+ gradeMeta,
36
+ } from '../../components/seo/primitives.js'
37
+ import { useSeoResource } from './useSeoResource.js'
38
+
39
+ function scoreFromCoverage(coverage: number, orphanCount: number, broken: number): number {
40
+ let score = coverage
41
+ score -= Math.min(30, orphanCount * 0.5)
42
+ score -= Math.min(20, broken * 2)
43
+ return Math.max(0, Math.min(100, Math.round(score)))
44
+ }
45
+
46
+ function ActionCard({
47
+ icon: Icon,
48
+ title,
49
+ description,
50
+ impact,
51
+ actionLabel,
52
+ onAction,
53
+ busy,
54
+ disabled,
55
+ }: {
56
+ icon: typeof Link2
57
+ title: string
58
+ description: string
59
+ impact: 'High' | 'Medium' | 'Low'
60
+ actionLabel: string
61
+ onAction: () => void
62
+ busy?: boolean
63
+ disabled?: boolean
64
+ }) {
65
+ const impactTone = impact === 'High' ? 'text-destructive' : 'text-warning'
66
+ return (
67
+ <div className="border-border bg-card flex flex-col gap-4 rounded-xl border p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between">
68
+ <div className="flex min-w-0 gap-4">
69
+ <div className="bg-primary/10 text-primary flex h-11 w-11 shrink-0 items-center justify-center rounded-lg">
70
+ <Icon className="h-5 w-5" aria-hidden />
71
+ </div>
72
+ <div className="min-w-0">
73
+ <h3 className="text-foreground text-sm font-medium">{title}</h3>
74
+ <p className="text-muted-foreground mt-1 text-sm">{description}</p>
75
+ <div className="mt-2 flex flex-wrap items-center gap-2">
76
+ <span className={`text-xs font-medium ${impactTone}`}>Impact: {impact}</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ <button type="button" className={btnPrimary} onClick={onAction} disabled={disabled || busy}>
81
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
82
+ {actionLabel}
83
+ </button>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ function SuggestionList({
89
+ suggestions,
90
+ loading,
91
+ }: {
92
+ suggestions: LinkSuggestion[]
93
+ loading: boolean
94
+ }) {
95
+ if (loading) {
96
+ return (
97
+ <p className="text-muted-foreground flex items-center gap-2 text-sm">
98
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
99
+ Finding AI link opportunities…
100
+ </p>
101
+ )
102
+ }
103
+ if (suggestions.length === 0) {
104
+ return (
105
+ <p className="text-muted-foreground text-sm">
106
+ No suggestions yet. Run AI indexing first, or add more published content.
107
+ </p>
108
+ )
109
+ }
110
+ return (
111
+ <ul className="divide-border divide-y rounded-lg border">
112
+ {suggestions.map((s) => (
113
+ <li key={`${s.sourceEntityId}-${s.anchorText}`} className="px-4 py-3">
114
+ <p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
115
+ <p className="text-muted-foreground mt-0.5 text-xs">
116
+ Link &ldquo;{s.anchorText}&rdquo; → {s.sourceUrl || 'target page'}{' '}
117
+ <span className="text-muted-foreground/80 tabular-nums">
118
+ ({Math.round(s.confidence * 100)}% match)
119
+ </span>
120
+ </p>
121
+ {s.excerpt && (
122
+ <p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
123
+ )}
124
+ </li>
125
+ ))}
126
+ </ul>
127
+ )
128
+ }
129
+
130
+ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
131
+ const { data, loading, error, refetch } = useSeoResource(fetchLinkGraphSnapshot, [])
132
+ const [indexing, setIndexing] = useState(false)
133
+ const [healthLoading, setHealthLoading] = useState(false)
134
+ const [healthIssues, setHealthIssues] = useState<
135
+ Awaited<ReturnType<typeof fetchLinkHealthIssues>>['data'] | null
136
+ >(null)
137
+ const [selectedOrphan, setSelectedOrphan] = useState<LinkGraphEntityRef | null>(null)
138
+ const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
139
+ const [suggestLoading, setSuggestLoading] = useState(false)
140
+
141
+ const totalLinks = (data?.internalLinks ?? 0) + (data?.externalLinks ?? 0)
142
+ const internalPct = totalLinks > 0 ? Math.round((data!.internalLinks / totalLinks) * 100) : 0
143
+ const externalPct = totalLinks > 0 ? 100 - internalPct : 0
144
+ const healthScore = useMemo(
145
+ () =>
146
+ data
147
+ ? scoreFromCoverage(data.linkCoveragePercent, data.orphanCount, data.brokenLinkCount)
148
+ : null,
149
+ [data],
150
+ )
151
+ const healthGrade =
152
+ healthScore !== null
153
+ ? gradeMeta(
154
+ healthScore >= 80
155
+ ? 'good'
156
+ : healthScore >= 60
157
+ ? 'fair'
158
+ : healthScore >= 40
159
+ ? 'poor'
160
+ : 'critical',
161
+ )
162
+ : null
163
+
164
+ const handleAiIndex = useCallback(async () => {
165
+ setIndexing(true)
166
+ const res = await runLinkAiIndex()
167
+ setIndexing(false)
168
+ if (res.error) {
169
+ toast.error(res.error)
170
+ return
171
+ }
172
+ toast.success(res.data?.message ?? 'Link index updated.')
173
+ refetch()
174
+ }, [refetch])
175
+
176
+ const handleLinkHealthScan = useCallback(async () => {
177
+ setHealthLoading(true)
178
+ const res = await fetchLinkHealthIssues()
179
+ setHealthLoading(false)
180
+ if (res.error) {
181
+ toast.error(res.error)
182
+ return
183
+ }
184
+ setHealthIssues(res.data ?? null)
185
+ toast.success(`Checked ${res.data?.checked ?? 0} links.`)
186
+ }, [])
187
+
188
+ const loadSuggestions = useCallback(async (orphan: LinkGraphEntityRef) => {
189
+ setSelectedOrphan(orphan)
190
+ setSuggestLoading(true)
191
+ setSuggestions([])
192
+ const res = await fetchLinkSuggestions({
193
+ targetEntityId: orphan.id,
194
+ targetEntityType: orphan.entityType,
195
+ })
196
+ setSuggestLoading(false)
197
+ if (res.error) {
198
+ toast.error(res.error)
199
+ return
200
+ }
201
+ setSuggestions(res.data?.suggestions ?? [])
202
+ }, [])
203
+
204
+ const openEditor = (ref: LinkGraphEntityRef) => {
205
+ const path = editPathForDoc(
206
+ { type: ref.entityType === 'post' ? 'post' : 'page' },
207
+ ref.collection,
208
+ ref.id,
209
+ )
210
+ onNavigate?.(path)
211
+ }
212
+
213
+ if (loading) return <SeoLoading label="Analyzing link graph…" />
214
+ if (error) return <SeoErrorState message={error} onRetry={refetch} />
215
+ if (!data)
216
+ return <SeoEmptyState title="No link data" description="Publish content to track links." />
217
+
218
+ return (
219
+ <div className="space-y-6">
220
+ <div>
221
+ <h2 className="text-foreground text-lg font-medium">Internal linking snapshot</h2>
222
+ <p className="text-muted-foreground mt-1 text-sm">
223
+ Track internal and external links across published pages and posts. Use AI to recover
224
+ orphaned content and improve link coverage.
225
+ </p>
226
+ </div>
227
+
228
+ {/* Distribution */}
229
+ <SectionCard title="Link distribution" description="Published content only">
230
+ <div className="space-y-3">
231
+ <div className="flex h-3 overflow-hidden rounded-full">
232
+ <div
233
+ className="bg-primary motion-safe:transition-all"
234
+ style={{ width: `${internalPct}%` }}
235
+ title={`Internal ${internalPct}%`}
236
+ />
237
+ <div
238
+ className="bg-success motion-safe:transition-all"
239
+ style={{ width: `${externalPct}%` }}
240
+ title={`External ${externalPct}%`}
241
+ />
242
+ </div>
243
+ <div className="flex flex-wrap gap-6 text-sm">
244
+ <span className="text-foreground">
245
+ <span className="bg-primary mr-2 inline-block h-2 w-2 rounded-full" aria-hidden />
246
+ Internal: {data.internalLinks.toLocaleString()} ({internalPct}%)
247
+ </span>
248
+ <span className="text-foreground">
249
+ <span className="bg-success mr-2 inline-block h-2 w-2 rounded-full" aria-hidden />
250
+ External: {data.externalLinks.toLocaleString()} ({externalPct}%)
251
+ </span>
252
+ </div>
253
+ {externalPct > 0 && externalPct < 15 && (
254
+ <p className="border-info/30 bg-info/10 text-info flex items-start gap-2 rounded-lg border p-4 text-sm">
255
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
256
+ External ratio is low. Consider citing relevant high-authority sources where
257
+ appropriate.
258
+ </p>
259
+ )}
260
+ </div>
261
+ </SectionCard>
262
+
263
+ {/* Score cards */}
264
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
265
+ <SectionCard title="Site link health">
266
+ {healthScore !== null && healthGrade && (
267
+ <div>
268
+ <span className={`text-3xl font-medium tabular-nums ${healthGrade.text}`}>
269
+ {healthScore}
270
+ </span>
271
+ <p className="text-muted-foreground mt-1 text-xs">{healthGrade.label}</p>
272
+ </div>
273
+ )}
274
+ </SectionCard>
275
+ <SectionCard title="Link coverage">
276
+ <span className="text-foreground text-3xl font-medium tabular-nums">
277
+ {data.linkCoveragePercent}%
278
+ </span>
279
+ <p className="text-muted-foreground mt-1 text-xs">Pages with inbound internal links</p>
280
+ </SectionCard>
281
+ <SectionCard title="Orphaned pages">
282
+ <span
283
+ className={`text-3xl font-medium tabular-nums ${data.orphanCount > 0 ? 'text-destructive' : 'text-success'}`}
284
+ >
285
+ {data.orphanCount}
286
+ </span>
287
+ <p className="text-muted-foreground mt-1 text-xs">No inbound internal links</p>
288
+ </SectionCard>
289
+ <SectionCard title="Broken links">
290
+ <span
291
+ className={`text-3xl font-medium tabular-nums ${data.brokenLinkCount > 0 ? 'text-warning' : 'text-success'}`}
292
+ >
293
+ {data.brokenLinkCount}
294
+ </span>
295
+ <p className="text-muted-foreground mt-1 text-xs">From latest SEO audit</p>
296
+ </SectionCard>
297
+ </div>
298
+
299
+ {/* AI actions */}
300
+ <div className="space-y-4">
301
+ <ActionCard
302
+ icon={Bot}
303
+ title="Perform basic AI scanning"
304
+ description="Index published content so AI can suggest which pages should link to each other. Re-run after publishing a significant batch of content."
305
+ impact="High"
306
+ actionLabel={indexing ? 'Indexing…' : 'Run AI index'}
307
+ onAction={handleAiIndex}
308
+ busy={indexing}
309
+ />
310
+ {data.aiIndexLastRunAt && (
311
+ <p className="text-muted-foreground -mt-2 text-xs">
312
+ Last indexed {new Date(data.aiIndexLastRunAt).toLocaleString()}
313
+ {data.aiIndexReady ? ' · AI ready' : ''}
314
+ </p>
315
+ )}
316
+ <ActionCard
317
+ icon={Link2}
318
+ title="Orphaned posts & pages"
319
+ description="These published URLs have no inbound internal links. Add links from related content so visitors and search engines can find them."
320
+ impact="High"
321
+ actionLabel="Review list below"
322
+ onAction={() => {
323
+ document.getElementById('orphan-list')?.scrollIntoView({ behavior: 'smooth' })
324
+ }}
325
+ />
326
+ <ActionCard
327
+ icon={Sparkles}
328
+ title="Link coverage"
329
+ description="Pages with fewer than two outbound internal links. Strengthen topical clusters by linking to related content."
330
+ impact="High"
331
+ actionLabel="Review under-linked"
332
+ onAction={() => {
333
+ document.getElementById('under-linked-list')?.scrollIntoView({ behavior: 'smooth' })
334
+ }}
335
+ />
336
+ </div>
337
+
338
+ {/* Orphan list */}
339
+ <SectionCard
340
+ title="Orphaned content"
341
+ description={`${data.orphanCount} published item${data.orphanCount === 1 ? '' : 's'} with no inbound internal links`}
342
+ >
343
+ <div id="orphan-list" className="space-y-4">
344
+ {data.orphaned.length === 0 ? (
345
+ <p className="text-muted-foreground flex items-center gap-2 text-sm">
346
+ <CheckCircle2 className="text-success h-4 w-4" aria-hidden />
347
+ No orphaned pages detected.
348
+ </p>
349
+ ) : (
350
+ <ul className="divide-border divide-y rounded-lg border">
351
+ {data.orphaned.map((row) => (
352
+ <li
353
+ key={row.id}
354
+ className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
355
+ >
356
+ <div className="min-w-0">
357
+ <p className="text-foreground truncate text-sm font-medium">{row.title}</p>
358
+ <p className="text-muted-foreground truncate text-xs">{row.url || row.path}</p>
359
+ </div>
360
+ <div className="flex shrink-0 flex-wrap gap-2">
361
+ <button type="button" className={btnSecondary} onClick={() => openEditor(row)}>
362
+ Edit
363
+ </button>
364
+ <button
365
+ type="button"
366
+ className={btnPrimary}
367
+ onClick={() => void loadSuggestions(row)}
368
+ >
369
+ <Sparkles className="h-3.5 w-3.5" aria-hidden />
370
+ AI suggest
371
+ </button>
372
+ </div>
373
+ </li>
374
+ ))}
375
+ </ul>
376
+ )}
377
+ {selectedOrphan && (
378
+ <div className="border-border bg-muted/30 rounded-lg border p-4">
379
+ <p className="text-foreground mb-2 text-sm font-medium">
380
+ AI suggestions for &ldquo;{selectedOrphan.title}&rdquo;
381
+ </p>
382
+ <SuggestionList suggestions={suggestions} loading={suggestLoading} />
383
+ </div>
384
+ )}
385
+ </div>
386
+ </SectionCard>
387
+
388
+ {/* Under-linked */}
389
+ <SectionCard title="Under-linked pages" description="Fewer than 2 outbound internal links">
390
+ <div id="under-linked-list">
391
+ {data.underLinked.length === 0 ? (
392
+ <p className="text-muted-foreground text-sm">
393
+ All crawled pages have adequate outbound links.
394
+ </p>
395
+ ) : (
396
+ <ul className="divide-border max-h-80 divide-y overflow-y-auto rounded-lg border">
397
+ {data.underLinked.map((row) => (
398
+ <li
399
+ key={row.id}
400
+ className="flex items-center justify-between gap-3 px-4 py-3 text-sm"
401
+ >
402
+ <span className="text-foreground min-w-0 truncate font-medium">{row.title}</span>
403
+ <span className="text-muted-foreground shrink-0 tabular-nums">
404
+ {row.outboundInternal} outbound
405
+ </span>
406
+ </li>
407
+ ))}
408
+ </ul>
409
+ )}
410
+ </div>
411
+ </SectionCard>
412
+
413
+ {/* Live link health */}
414
+ <SectionCard
415
+ title="Live link health scan"
416
+ description="Probe outbound URLs (rate-limited). Complements audit broken-link issues."
417
+ action={
418
+ <button
419
+ type="button"
420
+ className={btnSecondary}
421
+ onClick={() => void handleLinkHealthScan()}
422
+ disabled={healthLoading}
423
+ >
424
+ {healthLoading ? (
425
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
426
+ ) : (
427
+ <RefreshCw className="h-4 w-4" aria-hidden />
428
+ )}
429
+ Scan now
430
+ </button>
431
+ }
432
+ >
433
+ {healthIssues && healthIssues.issues.length > 0 ? (
434
+ <ul className="divide-border max-h-64 divide-y overflow-y-auto rounded-lg border">
435
+ {healthIssues.issues.map((issue) => (
436
+ <li key={issue.id} className="px-4 py-3 text-sm">
437
+ <p className="text-foreground font-medium">{issue.page}</p>
438
+ <p className="text-muted-foreground mt-0.5 flex items-center gap-1 truncate text-xs">
439
+ <ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
440
+ {issue.url}
441
+ <span className="text-destructive ml-1">HTTP {issue.status || 'error'}</span>
442
+ <span className="text-muted-foreground">· {issue.type}</span>
443
+ </p>
444
+ </li>
445
+ ))}
446
+ </ul>
447
+ ) : (
448
+ <p className="text-muted-foreground text-sm">
449
+ {healthIssues
450
+ ? 'No failing links in the last scan.'
451
+ : 'Run a scan to check live URL responses across published content.'}
452
+ </p>
453
+ )}
454
+ </SectionCard>
455
+
456
+ <p className="text-muted-foreground text-xs">
457
+ Crawled {data.postsCrawled.toLocaleString()} published documents · Updated{' '}
458
+ {new Date(data.scannedAt).toLocaleString()}
459
+ </p>
460
+ </div>
461
+ )
462
+ }
@@ -171,7 +171,7 @@ export function OverviewTab({
171
171
  ? 'bg-warning/10 text-warning'
172
172
  : 'bg-success/10 text-success'
173
173
  return (
174
- <li key={w.id} className="flex items-start justify-between gap-3 py-3 first:pt-0">
174
+ <li key={w.id} className="flex items-start justify-between gap-3 py-3">
175
175
  <div className="min-w-0">
176
176
  <p className="text-foreground text-sm font-medium">{w.title}</p>
177
177
  <p className="text-muted-foreground mt-0.5 text-sm">{w.explanation}</p>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useRef, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import {
5
5
  Plus,
6
6
  Trash2,
@@ -22,11 +22,14 @@ import {
22
22
  deleteRedirectRule,
23
23
  flattenRedirectChain,
24
24
  generate404RecoverySuggestions,
25
+ fetchRedirectRecoverySettings,
26
+ saveRedirectRecoverySettings,
25
27
  acceptRedirectSuggestion,
26
28
  dismissRedirectSuggestion,
27
29
  exportRedirectsCsv,
28
30
  importRedirectsCsv,
29
31
  type RedirectRule,
32
+ type RedirectRecoverySettings,
30
33
  } from '../../lib/seo-service.js'
31
34
  import {
32
35
  SectionCard,
@@ -66,6 +69,91 @@ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
66
69
  )
67
70
  }
68
71
 
72
+ function RecoverySettingsCard() {
73
+ const { data, loading, error, refetch } = useSeoResource(fetchRedirectRecoverySettings, [])
74
+ const [saving, setSaving] = useState(false)
75
+ const [local, setLocal] = useState<RedirectRecoverySettings | null>(null)
76
+
77
+ useEffect(() => {
78
+ if (data) setLocal(data)
79
+ }, [data])
80
+
81
+ async function handleSave() {
82
+ if (!local) return
83
+ setSaving(true)
84
+ const res = await saveRedirectRecoverySettings(local)
85
+ setSaving(false)
86
+ if (res.error) return toast.error(res.error)
87
+ toast.success('Recovery settings saved.')
88
+ refetch()
89
+ }
90
+
91
+ if (loading) return null
92
+ if (error) return null
93
+ if (!local) return null
94
+
95
+ return (
96
+ <SectionCard
97
+ title="404 recovery settings"
98
+ description="Control AI re-ranking and optional auto-redirect for high-confidence matches"
99
+ >
100
+ <div className="space-y-4">
101
+ <label className="flex items-start gap-3">
102
+ <input
103
+ type="checkbox"
104
+ className="border-border mt-1 h-4 w-4 rounded"
105
+ checked={local.aiRerank}
106
+ onChange={(e) => setLocal({ ...local, aiRerank: e.target.checked })}
107
+ />
108
+ <span>
109
+ <span className="text-foreground text-sm font-medium">AI re-ranking</span>
110
+ <span className="text-muted-foreground block text-sm">
111
+ Use semantic search to resolve ambiguous matches (40–84% heuristic confidence).
112
+ </span>
113
+ </span>
114
+ </label>
115
+ <label className="flex items-start gap-3">
116
+ <input
117
+ type="checkbox"
118
+ className="border-border mt-1 h-4 w-4 rounded"
119
+ checked={local.autoApply}
120
+ onChange={(e) => setLocal({ ...local, autoApply: e.target.checked })}
121
+ />
122
+ <span>
123
+ <span className="text-foreground text-sm font-medium">
124
+ Auto-apply high-confidence redirects
125
+ </span>
126
+ <span className="text-muted-foreground block text-sm">
127
+ Create 301 redirects automatically when confidence meets the threshold (off by
128
+ default).
129
+ </span>
130
+ </span>
131
+ </label>
132
+ <label className="block max-w-xs">
133
+ <span className="text-foreground text-sm font-medium">Auto-apply minimum confidence</span>
134
+ <input
135
+ type="number"
136
+ min={50}
137
+ max={100}
138
+ value={local.autoApplyMinConfidence}
139
+ disabled={!local.autoApply}
140
+ onChange={(e) =>
141
+ setLocal({
142
+ ...local,
143
+ autoApplyMinConfidence: Math.max(50, Math.min(100, Number(e.target.value) || 85)),
144
+ })
145
+ }
146
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring mt-1 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
147
+ />
148
+ </label>
149
+ <button className={btnSecondary} onClick={handleSave} disabled={saving}>
150
+ {saving ? 'Saving…' : 'Save settings'}
151
+ </button>
152
+ </div>
153
+ </SectionCard>
154
+ )
155
+ }
156
+
69
157
  function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
70
158
  const { data, loading, error, refetch } = useSeoResource(fetchRedirectSuggestions, [])
71
159
  const [generating, setGenerating] = useState(false)
@@ -78,7 +166,11 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
78
166
  toast.error(res.error)
79
167
  return
80
168
  }
81
- toast.success('Generated 404 recovery suggestions.')
169
+ const autoMsg =
170
+ res.autoApplied && res.autoApplied > 0
171
+ ? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
172
+ : ''
173
+ toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
82
174
  refetch()
83
175
  }
84
176
 
@@ -120,7 +212,7 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
120
212
  ) : (
121
213
  <ul className="divide-border divide-y">
122
214
  {data!.map((s) => (
123
- <li key={s.id} className="flex items-center justify-between gap-3 py-3 first:pt-0">
215
+ <li key={s.id} className="flex items-center justify-between gap-3 py-3">
124
216
  <div className="min-w-0">
125
217
  <p className="text-foreground flex items-center gap-2 text-sm">
126
218
  <span className="truncate font-medium">{s.fromPath}</span>
@@ -181,7 +273,7 @@ function ChainMapCard({ onFlattened }: { onFlattened: () => void }) {
181
273
  {data.map((chain) => (
182
274
  <li
183
275
  key={chain.id}
184
- className="border-border flex items-center justify-between gap-3 rounded-md border p-3"
276
+ className="border-border flex items-center justify-between gap-3 rounded-md border p-4"
185
277
  >
186
278
  <div className="min-w-0">
187
279
  <div className="mb-1 flex items-center gap-2">
@@ -272,6 +364,7 @@ export function RedirectsTab() {
272
364
  <OverviewCards onRefetchKey={refetchKey} />
273
365
 
274
366
  <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
367
+ <RecoverySettingsCard />
275
368
  <Ai404RecoveryCard onApplied={refetchAll} />
276
369
  <ChainMapCard onFlattened={refetchAll} />
277
370
  </div>
@@ -73,7 +73,7 @@ function Toggle({
73
73
  const labelId = useId()
74
74
  const helpId = useId()
75
75
  return (
76
- <div className="flex items-center justify-between gap-3 py-2.5">
76
+ <div className="flex items-center justify-between gap-3 py-3.5">
77
77
  <span className="min-w-0">
78
78
  <span id={labelId} className="text-foreground block text-sm font-medium">
79
79
  {label}
@@ -259,7 +259,7 @@ function RobotsCard() {
259
259
  <div className="space-y-3">
260
260
  {validation.blocksEntireSite && (
261
261
  <div
262
- className="border-destructive/30 bg-destructive/10 flex items-start gap-2 rounded-md border p-3"
262
+ className="border-destructive/30 bg-destructive/10 flex items-start gap-2 rounded-md border p-4"
263
263
  role="alert"
264
264
  >
265
265
  <AlertTriangle className="text-destructive mt-0.5 h-4 w-4 shrink-0" aria-hidden />
@@ -92,7 +92,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
92
92
  {ai.loadError && (
93
93
  <div
94
94
  role="alert"
95
- className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
95
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
96
96
  >
97
97
  <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
98
98
  <span className="flex-1">{ai.loadError}</span>
@@ -107,7 +107,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
107
107
  )}
108
108
 
109
109
  {!canEdit && (
110
- <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
110
+ <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-4 text-sm">
111
111
  <Info size={16} className="shrink-0" aria-hidden="true" />
112
112
  You have read-only access to AI settings. Only administrators can make changes.
113
113
  </div>
@@ -145,7 +145,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
145
145
  {ai.saveState === 'error' && ai.saveError && (
146
146
  <div
147
147
  role="alert"
148
- className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
148
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
149
149
  >
150
150
  <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
151
151
  <span className="flex-1">{ai.saveError}</span>
@@ -85,7 +85,7 @@ function FeatureRow({
85
85
  : 'Use default model'
86
86
 
87
87
  return (
88
- <div className="py-4 first:pt-0 last:pb-0">
88
+ <div className="py-4">
89
89
  <SettingsToggleRow
90
90
  label={feature.label}
91
91
  description={feature.description}