@prenta/admin 1.14.0 → 1.15.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/CHANGELOG.md +10 -0
- package/dist/__tests__/lib/api.test.d.ts +2 -0
- package/dist/__tests__/lib/api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/api.test.js +58 -0
- package/dist/__tests__/lib/api.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js +171 -0
- package/dist/__tests__/lib/seo-service-autopilot.test.js.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +28 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +30 -12
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js +330 -0
- package/dist/__tests__/views/seo-autopilot-applied.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js +512 -0
- package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +139 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +41 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/lib/api.d.ts +13 -2
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +1 -2
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/seo-service.d.ts +206 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +139 -5
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.js +1 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AutopilotAppliedSection.d.ts +10 -0
- package/dist/views/seo/AutopilotAppliedSection.d.ts.map +1 -0
- package/dist/views/seo/AutopilotAppliedSection.js +295 -0
- package/dist/views/seo/AutopilotAppliedSection.js.map +1 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +53 -17
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts +3 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +14 -19
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/relative-age.d.ts +7 -0
- package/dist/views/seo/relative-age.d.ts.map +1 -0
- package/dist/views/seo/relative-age.js +20 -0
- package/dist/views/seo/relative-age.js.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts +30 -0
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -0
- package/dist/views/settings/SeoAutopilotCard.js +375 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/components.d.ts +3 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -2
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/api.test.ts +76 -0
- package/src/__tests__/lib/seo-service-autopilot.test.ts +196 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +29 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +36 -16
- package/src/__tests__/views/seo-autopilot-applied.render.test.tsx +382 -0
- package/src/__tests__/views/seo-autopilot-card.render.test.tsx +610 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +161 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +334 -6
- package/src/views/SEO.tsx +1 -1
- package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
- package/src/views/seo/ProposalsTab.tsx +92 -12
- package/src/views/seo/RedirectsTab.tsx +39 -60
- package/src/views/seo/relative-age.ts +15 -0
- package/src/views/settings/SeoAutopilotCard.tsx +826 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
- package/src/views/settings/components.tsx +10 -1
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
11
11
|
import {
|
|
12
|
+
AlertTriangle,
|
|
12
13
|
ArrowRightLeft,
|
|
14
|
+
Bot,
|
|
13
15
|
Check,
|
|
14
16
|
ChevronDown,
|
|
15
17
|
Inbox,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
generateSeoProposals,
|
|
28
30
|
type SeoProposal,
|
|
29
31
|
type SeoProposalBulkApplyItem,
|
|
32
|
+
type SeoProposalsPayload,
|
|
30
33
|
} from '../../lib/seo-service.js'
|
|
31
34
|
import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
32
35
|
import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
|
|
@@ -42,10 +45,13 @@ import {
|
|
|
42
45
|
btnSecondary,
|
|
43
46
|
} from '../../components/seo/primitives.js'
|
|
44
47
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
48
|
+
import { AutopilotAppliedSection } from './AutopilotAppliedSection.js'
|
|
49
|
+
import { relativeAge } from './relative-age.js'
|
|
45
50
|
import { useSeoResource } from './useSeoResource.js'
|
|
46
51
|
|
|
47
52
|
const GENERATE_BATCH_SIZE = 10
|
|
48
53
|
const AI_SETTINGS_PATH = '/settings?tab=ai'
|
|
54
|
+
const SEO_SETTINGS_PATH = '/settings?tab=seo'
|
|
49
55
|
/** Stable empty list so memoised derivations don't recompute while loading. */
|
|
50
56
|
const NO_PROPOSALS: SeoProposal[] = []
|
|
51
57
|
|
|
@@ -56,17 +62,6 @@ function errorMessage(err: unknown): string {
|
|
|
56
62
|
return err instanceof Error && err.message ? err.message : 'Request failed.'
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
function relativeAge(iso: string | null): string {
|
|
60
|
-
if (!iso) return ''
|
|
61
|
-
const ms = Date.now() - new Date(iso).getTime()
|
|
62
|
-
if (!Number.isFinite(ms) || ms < 0) return ''
|
|
63
|
-
const minutes = Math.round(ms / 60_000)
|
|
64
|
-
if (minutes < 60) return `${Math.max(1, minutes)}m ago`
|
|
65
|
-
const hours = Math.round(minutes / 60)
|
|
66
|
-
if (hours < 48) return `${hours}h ago`
|
|
67
|
-
return `${Math.round(hours / 24)}d ago`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
65
|
function confidenceTone(c: number): 'good' | 'fair' | 'poor' {
|
|
71
66
|
return c >= 0.7 ? 'good' : c >= 0.4 ? 'fair' : 'poor'
|
|
72
67
|
}
|
|
@@ -122,6 +117,60 @@ function SourceBadge({ source }: { source: SeoProposal['source'] }) {
|
|
|
122
117
|
}
|
|
123
118
|
}
|
|
124
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Short chip wording for the engine-level pause reasons the server emits
|
|
122
|
+
* (`AutopilotPauseReason`). An unknown value falls through verbatim so a new
|
|
123
|
+
* reason is still visible rather than hidden behind a generic label.
|
|
124
|
+
*/
|
|
125
|
+
const CHIP_PAUSE_LABEL: Record<string, string> = {
|
|
126
|
+
outcome_regression: 'traffic regression',
|
|
127
|
+
owner_unavailable: 'owner unavailable',
|
|
128
|
+
manual: 'paused by admin',
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function chipPauseLabel(reason: string): string {
|
|
132
|
+
return CHIP_PAUSE_LABEL[reason] ?? reason
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Header chip summarising the autopilot's state. Hidden while autopilot is off
|
|
137
|
+
* (nothing to report — including an engine disabled while it was still paused,
|
|
138
|
+
* since `PUT { enabled: false }` does not clear the pause); a paused engine
|
|
139
|
+
* gets the warning tone and names the reason. Clicking opens the Autopilot
|
|
140
|
+
* card in Settings → SEO.
|
|
141
|
+
*/
|
|
142
|
+
function AutopilotChip({
|
|
143
|
+
autopilot,
|
|
144
|
+
onNavigate,
|
|
145
|
+
}: {
|
|
146
|
+
autopilot: SeoProposalsPayload['autopilot']
|
|
147
|
+
onNavigate?: (path: string) => void
|
|
148
|
+
}) {
|
|
149
|
+
if (!autopilot.enabled) return null
|
|
150
|
+
const paused = autopilot.pausedReason !== null
|
|
151
|
+
const label =
|
|
152
|
+
autopilot.pausedReason !== null
|
|
153
|
+
? `Autopilot paused — ${chipPauseLabel(autopilot.pausedReason)}`
|
|
154
|
+
: `Autopilot on · ${autopilot.appliedLast7d} applied this week`
|
|
155
|
+
const tone = paused
|
|
156
|
+
? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
|
|
157
|
+
: 'border-border bg-muted/40 text-foreground hover:bg-accent'
|
|
158
|
+
return (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className={`focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${tone}`}
|
|
162
|
+
onClick={() => onNavigate?.(SEO_SETTINGS_PATH)}
|
|
163
|
+
>
|
|
164
|
+
{paused ? (
|
|
165
|
+
<AlertTriangle className="text-warning h-3.5 w-3.5" aria-hidden />
|
|
166
|
+
) : (
|
|
167
|
+
<Bot className="h-3.5 w-3.5" aria-hidden />
|
|
168
|
+
)}
|
|
169
|
+
{label}
|
|
170
|
+
</button>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
125
174
|
export function ProposalsTab({
|
|
126
175
|
planInfo,
|
|
127
176
|
onChanged,
|
|
@@ -139,6 +188,8 @@ export function ProposalsTab({
|
|
|
139
188
|
const [mutation, setMutation] = useState<Mutation | null>(null)
|
|
140
189
|
const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
|
|
141
190
|
const [generating, setGenerating] = useState<{ done: number; total: number } | null>(null)
|
|
191
|
+
/** Bumped after every mutation so the autopilot ledger below refetches with the inbox. */
|
|
192
|
+
const [refreshKey, setRefreshKey] = useState(0)
|
|
142
193
|
const stopRef = useRef(false)
|
|
143
194
|
/** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
|
|
144
195
|
const mutationRef = useRef<Mutation | null>(null)
|
|
@@ -202,6 +253,17 @@ export function ProposalsTab({
|
|
|
202
253
|
|
|
203
254
|
const afterMutation = useCallback(() => {
|
|
204
255
|
setSelected(new Set())
|
|
256
|
+
refetch()
|
|
257
|
+
setRefreshKey((k) => k + 1)
|
|
258
|
+
onChanged?.()
|
|
259
|
+
}, [refetch, onChanged])
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A revert in the ledger reopens its issue / suggestion server-side, so the
|
|
263
|
+
* inbox and the SEO page refetch. Not `afterMutation`: that bumps
|
|
264
|
+
* `refreshKey`, and the ledger already reloads itself after a revert.
|
|
265
|
+
*/
|
|
266
|
+
const handleLedgerChanged = useCallback(() => {
|
|
205
267
|
refetch()
|
|
206
268
|
onChanged?.()
|
|
207
269
|
}, [refetch, onChanged])
|
|
@@ -367,6 +429,18 @@ export function ProposalsTab({
|
|
|
367
429
|
)
|
|
368
430
|
) : null
|
|
369
431
|
|
|
432
|
+
// `data?.autopilot` (not just `data`): a core deployed before autopilot omits the field.
|
|
433
|
+
const autopilotChip = data?.autopilot ? (
|
|
434
|
+
<AutopilotChip autopilot={data.autopilot} onNavigate={onNavigate} />
|
|
435
|
+
) : null
|
|
436
|
+
const headerAction =
|
|
437
|
+
autopilotChip || generateAction ? (
|
|
438
|
+
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
439
|
+
{autopilotChip}
|
|
440
|
+
{generateAction}
|
|
441
|
+
</div>
|
|
442
|
+
) : undefined
|
|
443
|
+
|
|
370
444
|
return (
|
|
371
445
|
<div className="space-y-6">
|
|
372
446
|
{!inlineApplyEnabled ? (
|
|
@@ -376,7 +450,7 @@ export function ProposalsTab({
|
|
|
376
450
|
<SectionCard
|
|
377
451
|
title="Proposals"
|
|
378
452
|
description="Review, approve, or dismiss what the AI proposes across issue fixes and 404 redirects."
|
|
379
|
-
action={
|
|
453
|
+
action={headerAction}
|
|
380
454
|
>
|
|
381
455
|
{loading ? (
|
|
382
456
|
<SeoLoading />
|
|
@@ -526,6 +600,12 @@ export function ProposalsTab({
|
|
|
526
600
|
)}
|
|
527
601
|
</SectionCard>
|
|
528
602
|
|
|
603
|
+
<AutopilotAppliedSection
|
|
604
|
+
onNavigate={onNavigate}
|
|
605
|
+
refreshKey={refreshKey}
|
|
606
|
+
onChanged={handleLedgerChanged}
|
|
607
|
+
/>
|
|
608
|
+
|
|
529
609
|
<ConfirmDialog
|
|
530
610
|
open={confirmApply}
|
|
531
611
|
onClose={() => setConfirmApply(false)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react'
|
|
4
4
|
import {
|
|
5
5
|
Plus,
|
|
6
6
|
Trash2,
|
|
@@ -46,6 +46,8 @@ import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../../components/
|
|
|
46
46
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
47
47
|
import { useSeoResource } from './useSeoResource.js'
|
|
48
48
|
|
|
49
|
+
const SEO_SETTINGS_PATH = '/settings?tab=seo'
|
|
50
|
+
|
|
49
51
|
function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
|
|
50
52
|
const { data, loading, error, refetch } = useSeoResource(fetchRedirectOverview, [onRefetchKey])
|
|
51
53
|
if (loading) return <SeoLoading label="Loading redirect overview…" />
|
|
@@ -70,10 +72,16 @@ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
|
|
|
70
72
|
)
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
/**
|
|
76
|
+
* 404 recovery settings. Only AI re-ranking lives here; the auto-apply policy
|
|
77
|
+
* (threshold, budget, gate) moved to SEO Autopilot, so the card points there
|
|
78
|
+
* instead of exposing a second copy of the controls.
|
|
79
|
+
*/
|
|
80
|
+
function RecoverySettingsCard({ onNavigate }: { onNavigate?: (path: string) => void }) {
|
|
74
81
|
const { data, loading, error, refetch } = useSeoResource(fetchRedirectRecoverySettings, [])
|
|
75
82
|
const [saving, setSaving] = useState(false)
|
|
76
83
|
const [local, setLocal] = useState<RedirectRecoverySettings | null>(null)
|
|
84
|
+
const aiRerankId = useId()
|
|
77
85
|
|
|
78
86
|
useEffect(() => {
|
|
79
87
|
if (data) setLocal(data)
|
|
@@ -82,7 +90,7 @@ function RecoverySettingsCard() {
|
|
|
82
90
|
async function handleSave() {
|
|
83
91
|
if (!local) return
|
|
84
92
|
setSaving(true)
|
|
85
|
-
const res = await saveRedirectRecoverySettings(local)
|
|
93
|
+
const res = await saveRedirectRecoverySettings({ aiRerank: local.aiRerank })
|
|
86
94
|
setSaving(false)
|
|
87
95
|
if (res.error) return toast.error(res.error)
|
|
88
96
|
toast.success('Recovery settings saved.')
|
|
@@ -96,58 +104,39 @@ function RecoverySettingsCard() {
|
|
|
96
104
|
return (
|
|
97
105
|
<SectionCard
|
|
98
106
|
title="404 recovery settings"
|
|
99
|
-
description="Control
|
|
107
|
+
description="Control how suggested redirects are ranked before you review them"
|
|
100
108
|
>
|
|
101
109
|
<div className="space-y-4">
|
|
102
|
-
<
|
|
110
|
+
<div className="flex items-start gap-3">
|
|
103
111
|
<input
|
|
112
|
+
id={aiRerankId}
|
|
104
113
|
type="checkbox"
|
|
105
|
-
className="border-border mt-1 h-4 w-4 rounded"
|
|
114
|
+
className="border-border focus-visible:ring-ring mt-1 h-4 w-4 rounded focus-visible:ring-2 focus-visible:outline-none"
|
|
106
115
|
checked={local.aiRerank}
|
|
107
116
|
onChange={(e) => setLocal({ ...local, aiRerank: e.target.checked })}
|
|
117
|
+
aria-describedby={`${aiRerankId}-help`}
|
|
108
118
|
/>
|
|
109
|
-
<
|
|
110
|
-
<
|
|
111
|
-
|
|
119
|
+
<div>
|
|
120
|
+
<label htmlFor={aiRerankId} className="text-foreground text-sm font-medium">
|
|
121
|
+
AI re-ranking
|
|
122
|
+
</label>
|
|
123
|
+
<p id={`${aiRerankId}-help`} className="text-muted-foreground text-sm">
|
|
112
124
|
Use semantic search to resolve ambiguous matches (40–84% heuristic confidence).
|
|
113
|
-
</
|
|
114
|
-
</
|
|
115
|
-
</
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
Create 301 redirects automatically when confidence meets the threshold (off by
|
|
129
|
-
default).
|
|
130
|
-
</span>
|
|
131
|
-
</span>
|
|
132
|
-
</label>
|
|
133
|
-
<label className="block max-w-xs">
|
|
134
|
-
<span className="text-foreground text-sm font-medium">Auto-apply minimum confidence</span>
|
|
135
|
-
<input
|
|
136
|
-
type="number"
|
|
137
|
-
min={50}
|
|
138
|
-
max={100}
|
|
139
|
-
value={local.autoApplyMinConfidence}
|
|
140
|
-
disabled={!local.autoApply}
|
|
141
|
-
onChange={(e) =>
|
|
142
|
-
setLocal({
|
|
143
|
-
...local,
|
|
144
|
-
autoApplyMinConfidence: Math.max(50, Math.min(100, Number(e.target.value) || 85)),
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
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"
|
|
148
|
-
/>
|
|
149
|
-
</label>
|
|
150
|
-
<button className={btnSecondary} onClick={handleSave} disabled={saving}>
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<p className="text-muted-foreground text-sm">
|
|
129
|
+
Auto-apply for high-confidence redirects is managed by{' '}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
className="text-primary focus-visible:ring-ring rounded-sm font-medium underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
|
133
|
+
onClick={() => onNavigate?.(SEO_SETTINGS_PATH)}
|
|
134
|
+
>
|
|
135
|
+
SEO Autopilot
|
|
136
|
+
</button>
|
|
137
|
+
.
|
|
138
|
+
</p>
|
|
139
|
+
<button type="button" className={btnSecondary} onClick={handleSave} disabled={saving}>
|
|
151
140
|
{saving ? 'Saving…' : 'Save settings'}
|
|
152
141
|
</button>
|
|
153
142
|
</div>
|
|
@@ -167,17 +156,7 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
|
|
|
167
156
|
toast.error(res.error)
|
|
168
157
|
return
|
|
169
158
|
}
|
|
170
|
-
|
|
171
|
-
res.autoApplied && res.autoApplied > 0
|
|
172
|
-
? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
|
|
173
|
-
: ''
|
|
174
|
-
toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
|
|
175
|
-
if (res.governanceBlocked) {
|
|
176
|
-
// AI governance vetoed the auto-apply (eligible plans were stored as
|
|
177
|
-
// pending suggestions). Tell the operator why, since the SEO module's
|
|
178
|
-
// "Auto-apply" toggle alone no longer decides.
|
|
179
|
-
toast.info(`Auto-apply skipped: ${res.governanceBlocked}`)
|
|
180
|
-
}
|
|
159
|
+
toast.success('Generated 404 recovery suggestions.')
|
|
181
160
|
refetch()
|
|
182
161
|
}
|
|
183
162
|
|
|
@@ -313,7 +292,7 @@ function ChainMapCard({ onFlattened }: { onFlattened: () => void }) {
|
|
|
313
292
|
)
|
|
314
293
|
}
|
|
315
294
|
|
|
316
|
-
export function RedirectsTab() {
|
|
295
|
+
export function RedirectsTab({ onNavigate }: { onNavigate?: (path: string) => void }) {
|
|
317
296
|
const [refetchKey, setRefetchKey] = useState(0)
|
|
318
297
|
const { data, loading, error, refetch } = useSeoResource(fetchRedirectRules, [refetchKey])
|
|
319
298
|
const [editor, setEditor] = useState<{ rule: RedirectRule | null } | null>(null)
|
|
@@ -371,7 +350,7 @@ export function RedirectsTab() {
|
|
|
371
350
|
<OverviewCards onRefetchKey={refetchKey} />
|
|
372
351
|
|
|
373
352
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
374
|
-
<RecoverySettingsCard />
|
|
353
|
+
<RecoverySettingsCard onNavigate={onNavigate} />
|
|
375
354
|
<Ai404RecoveryCard onApplied={refetchAll} />
|
|
376
355
|
<ChainMapCard onFlattened={refetchAll} />
|
|
377
356
|
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact "how long ago" label shared by the SEO review surfaces (proposal age,
|
|
3
|
+
* autopilot ledger). Returns `''` for missing, unparsable, or future timestamps
|
|
4
|
+
* so callers can fall back to an absolute date.
|
|
5
|
+
*/
|
|
6
|
+
export function relativeAge(iso: string | null, now: number = Date.now()): string {
|
|
7
|
+
if (!iso) return ''
|
|
8
|
+
const ms = now - new Date(iso).getTime()
|
|
9
|
+
if (!Number.isFinite(ms) || ms < 0) return ''
|
|
10
|
+
const minutes = Math.round(ms / 60_000)
|
|
11
|
+
if (minutes < 60) return `${Math.max(1, minutes)}m ago`
|
|
12
|
+
const hours = Math.round(minutes / 60)
|
|
13
|
+
if (hours < 48) return `${hours}h ago`
|
|
14
|
+
return `${Math.round(hours / 24)}d ago`
|
|
15
|
+
}
|