@prenta/admin 1.13.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 +31 -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.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +99 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
- package/dist/__tests__/router/seo-tab-for-path.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.d.ts +2 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +431 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
- 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/components/seo/ChangePreview.d.ts +6 -0
- package/dist/components/seo/ChangePreview.d.ts.map +1 -0
- package/dist/components/seo/ChangePreview.js +18 -0
- package/dist/components/seo/ChangePreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +1 -14
- package/dist/components/seo/SeoIssueFixPanel.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 +293 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +170 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +28 -9
- 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 +7 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
- package/dist/views/seo/ProposalsTab.js +334 -0
- package/dist/views/seo/ProposalsTab.js.map +1 -0
- 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 +108 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +1 -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 +531 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +47 -7
- package/src/components/seo/ChangePreview.tsx +47 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
- package/src/lib/api.ts +19 -3
- package/src/lib/seo-service.ts +455 -2
- package/src/views/SEO.tsx +41 -8
- package/src/views/seo/AutopilotAppliedSection.tsx +597 -0
- package/src/views/seo/ProposalsTab.tsx +761 -0
- 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
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SEO → Proposals "Applied by autopilot": the ledger of every unattended change
|
|
5
|
+
* (spec §4), newest first, with its gate results and Search Console outcome.
|
|
6
|
+
* Revert is admin-only (`canRevert` from the list payload) and confirmed; a 409
|
|
7
|
+
* conflict re-prompts with the fields a human edited since and offers a forced
|
|
8
|
+
* retry. One mutation is allowed in flight at a time (same guard pattern as
|
|
9
|
+
* `ProposalsTab`).
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
12
|
+
import { ArrowRight, Bot, Undo2 } from 'lucide-react'
|
|
13
|
+
import { toast } from 'sonner'
|
|
14
|
+
import {
|
|
15
|
+
fetchAutopilotApplied,
|
|
16
|
+
revertAutopilotChange,
|
|
17
|
+
type SeoAutoApplyList,
|
|
18
|
+
type SeoAutoApplyOutcome,
|
|
19
|
+
type SeoAutoApplyRow,
|
|
20
|
+
type SeoAutoApplyStatus,
|
|
21
|
+
} from '../../lib/seo-service.js'
|
|
22
|
+
import { sourceEditPath } from '../../lib/collection-routes.js'
|
|
23
|
+
import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
|
|
24
|
+
import {
|
|
25
|
+
SectionCard,
|
|
26
|
+
SeoEmptyState,
|
|
27
|
+
SeoErrorState,
|
|
28
|
+
SeoLoading,
|
|
29
|
+
SeoStatusBadge,
|
|
30
|
+
btnSecondary,
|
|
31
|
+
} from '../../components/seo/primitives.js'
|
|
32
|
+
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
33
|
+
import { relativeAge } from './relative-age.js'
|
|
34
|
+
|
|
35
|
+
const PAGE_SIZE = 20
|
|
36
|
+
const SEO_SETTINGS_PATH = '/settings?tab=seo'
|
|
37
|
+
const SELECT_CLASS =
|
|
38
|
+
'border-border bg-input-background text-foreground focus-visible:ring-ring rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60'
|
|
39
|
+
/** Compact secondary button for table rows (parity with the Settings autopilot card). */
|
|
40
|
+
const ROW_BUTTON_CLASS =
|
|
41
|
+
'border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
42
|
+
const PAGER_BUTTON_CLASS =
|
|
43
|
+
'text-foreground hover:bg-accent focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
|
44
|
+
|
|
45
|
+
/** The one mutation this section performs; every Revert locks while it runs. */
|
|
46
|
+
type Mutation = 'revert'
|
|
47
|
+
|
|
48
|
+
const STATUS_OPTIONS: ReadonlyArray<{ value: SeoAutoApplyStatus | ''; label: string }> = [
|
|
49
|
+
{ value: '', label: 'All statuses' },
|
|
50
|
+
{ value: 'applied', label: 'Applied' },
|
|
51
|
+
{ value: 'reverted', label: 'Reverted' },
|
|
52
|
+
{ value: 'outcome_reverted', label: 'Reverted after regression' },
|
|
53
|
+
{ value: 'verify_failed', label: 'Verify failed' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const OUTCOME_OPTIONS: ReadonlyArray<{ value: SeoAutoApplyOutcome | ''; label: string }> = [
|
|
57
|
+
{ value: '', label: 'All outcomes' },
|
|
58
|
+
{ value: 'pending', label: 'Watching' },
|
|
59
|
+
{ value: 'held', label: 'Held' },
|
|
60
|
+
{ value: 'regressed', label: 'Regressed' },
|
|
61
|
+
{ value: 'insufficient_data', label: 'Not enough data' },
|
|
62
|
+
{ value: 'skipped', label: 'Not watched' },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
function isStatus(v: string): v is SeoAutoApplyStatus {
|
|
66
|
+
return STATUS_OPTIONS.some((o) => o.value !== '' && o.value === v)
|
|
67
|
+
}
|
|
68
|
+
function isOutcome(v: string): v is SeoAutoApplyOutcome {
|
|
69
|
+
return OUTCOME_OPTIONS.some((o) => o.value !== '' && o.value === v)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function StatusBadge({ status }: { status: SeoAutoApplyStatus }) {
|
|
73
|
+
switch (status) {
|
|
74
|
+
case 'applied':
|
|
75
|
+
return <SeoStatusBadge label="Applied" tone="good" />
|
|
76
|
+
case 'verify_failed':
|
|
77
|
+
return <SeoStatusBadge label="Verify failed" tone="poor" />
|
|
78
|
+
case 'reverted':
|
|
79
|
+
return <SeoStatusBadge label="Reverted" tone="neutral" />
|
|
80
|
+
case 'outcome_reverted':
|
|
81
|
+
return <SeoStatusBadge label="Reverted · regression" tone="fair" />
|
|
82
|
+
default: {
|
|
83
|
+
const _exhaustive: never = status
|
|
84
|
+
return _exhaustive
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Only outcomes that carry information get a badge; `pending` / `skipped` stay quiet. */
|
|
90
|
+
function OutcomeBadge({ outcome }: { outcome: SeoAutoApplyOutcome }) {
|
|
91
|
+
switch (outcome) {
|
|
92
|
+
case 'pending':
|
|
93
|
+
case 'skipped':
|
|
94
|
+
return null
|
|
95
|
+
case 'held':
|
|
96
|
+
return <SeoStatusBadge label="Held" tone="good" />
|
|
97
|
+
case 'regressed':
|
|
98
|
+
return <SeoStatusBadge label="Regressed" tone="poor" />
|
|
99
|
+
case 'insufficient_data':
|
|
100
|
+
return <SeoStatusBadge label="Not enough data" tone="neutral" />
|
|
101
|
+
default: {
|
|
102
|
+
const _exhaustive: never = outcome
|
|
103
|
+
return _exhaustive
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function KindBadge({ kind }: { kind: SeoAutoApplyRow['kind'] }) {
|
|
109
|
+
switch (kind) {
|
|
110
|
+
case 'issue-fix':
|
|
111
|
+
return <SeoStatusBadge label="Issue fix" tone="neutral" />
|
|
112
|
+
case 'redirect':
|
|
113
|
+
return <SeoStatusBadge label="Redirect" tone="neutral" />
|
|
114
|
+
default: {
|
|
115
|
+
const _exhaustive: never = kind
|
|
116
|
+
return _exhaustive
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `missing-meta-title` → `Missing meta title`; the redirect pseudo-type names its producer. */
|
|
122
|
+
function issueTypeLabel(issueType: string): string {
|
|
123
|
+
if (issueType === 'redirect') return '404 recovery'
|
|
124
|
+
const words = issueType.replace(/[-_]+/g, ' ').trim()
|
|
125
|
+
return words ? words.charAt(0).toUpperCase() + words.slice(1) : issueType
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `metaTitle` → `Meta title`. */
|
|
129
|
+
function fieldLabel(field: string): string {
|
|
130
|
+
const spaced = field.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[-_]+/g, ' ')
|
|
131
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatValue(v: unknown): string {
|
|
135
|
+
if (v === null || v === undefined || v === '') return '—'
|
|
136
|
+
if (typeof v === 'string') return v
|
|
137
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
138
|
+
return JSON.stringify(v)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function displayTitle(row: SeoAutoApplyRow): string {
|
|
142
|
+
if (row.kind === 'redirect') return formatValue(row.after.source ?? row.entityUrl)
|
|
143
|
+
return row.entityTitle ?? row.entityUrl ?? row.path ?? issueTypeLabel(row.issueType)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function AppliedTime({ iso }: { iso: string }) {
|
|
147
|
+
const absolute = new Date(iso).toLocaleString()
|
|
148
|
+
const relative = relativeAge(iso)
|
|
149
|
+
return (
|
|
150
|
+
<time dateTime={iso} title={absolute}>
|
|
151
|
+
{relative || absolute}
|
|
152
|
+
{relative ? <span className="sr-only"> ({absolute})</span> : null}
|
|
153
|
+
</time>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function Diff({ row }: { row: SeoAutoApplyRow }) {
|
|
158
|
+
if (row.kind === 'redirect') {
|
|
159
|
+
return (
|
|
160
|
+
<p className="text-foreground flex flex-wrap items-center gap-1 font-mono text-sm break-all">
|
|
161
|
+
<span>{formatValue(row.after.source)}</span>
|
|
162
|
+
<ArrowRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
163
|
+
<span className="sr-only">to</span>
|
|
164
|
+
<span className="text-muted-foreground">{formatValue(row.after.destination)}</span>
|
|
165
|
+
</p>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const fields = Object.keys(row.after)
|
|
169
|
+
if (fields.length === 0) return <span className="text-muted-foreground text-sm">—</span>
|
|
170
|
+
return (
|
|
171
|
+
<ul className="space-y-1 text-sm">
|
|
172
|
+
{fields.map((field) => (
|
|
173
|
+
<li key={field} className="flex flex-wrap items-baseline gap-x-1.5">
|
|
174
|
+
<span className="text-muted-foreground">{fieldLabel(field)}:</span>
|
|
175
|
+
<span className="text-muted-foreground line-through">
|
|
176
|
+
{formatValue(row.before?.[field])}
|
|
177
|
+
</span>
|
|
178
|
+
<ArrowRight className="text-muted-foreground h-3 w-3 shrink-0 self-center" aria-hidden />
|
|
179
|
+
<span className="sr-only">to</span>
|
|
180
|
+
<span className="text-foreground">{formatValue(row.after[field])}</span>
|
|
181
|
+
</li>
|
|
182
|
+
))}
|
|
183
|
+
</ul>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function Quality({ gate }: { gate: SeoAutoApplyRow['gate'] }) {
|
|
188
|
+
if (gate.brandAlignment !== null) {
|
|
189
|
+
return (
|
|
190
|
+
<span
|
|
191
|
+
className="border-border bg-muted/40 inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
|
192
|
+
aria-label={`Brand voice score ${gate.brandAlignment} out of 100`}
|
|
193
|
+
>
|
|
194
|
+
<span className={`font-medium ${brandAlignmentTone(gate.brandAlignment)}`}>
|
|
195
|
+
{gate.brandAlignment}/100
|
|
196
|
+
</span>
|
|
197
|
+
<span className="text-muted-foreground">{brandAlignmentLabel(gate.brandAlignment)}</span>
|
|
198
|
+
</span>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
if (gate.confidence !== null) {
|
|
202
|
+
const pct = Math.round(gate.confidence * 100)
|
|
203
|
+
return (
|
|
204
|
+
<SeoStatusBadge label={`${pct}%`} tone={pct >= 70 ? 'good' : pct >= 40 ? 'fair' : 'poor'} />
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
return <SeoStatusBadge label="Rule-based" tone="neutral" />
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function AutopilotAppliedSection({
|
|
211
|
+
onNavigate,
|
|
212
|
+
onChanged,
|
|
213
|
+
refreshKey = 0,
|
|
214
|
+
}: {
|
|
215
|
+
onNavigate?: (path: string) => void
|
|
216
|
+
/**
|
|
217
|
+
* Fired after a successful revert. The server reopens the originating issue
|
|
218
|
+
* or redirect suggestion, so the parent inbox / overview must refetch.
|
|
219
|
+
*/
|
|
220
|
+
onChanged?: () => void
|
|
221
|
+
refreshKey?: number
|
|
222
|
+
}) {
|
|
223
|
+
const baseId = useId()
|
|
224
|
+
const [data, setData] = useState<SeoAutoApplyList | null>(null)
|
|
225
|
+
const [loading, setLoading] = useState(true)
|
|
226
|
+
const [error, setError] = useState<string | null>(null)
|
|
227
|
+
const [page, setPage] = useState(1)
|
|
228
|
+
const [status, setStatus] = useState<SeoAutoApplyStatus | ''>('')
|
|
229
|
+
const [outcome, setOutcome] = useState<SeoAutoApplyOutcome | ''>('')
|
|
230
|
+
const [target, setTarget] = useState<SeoAutoApplyRow | null>(null)
|
|
231
|
+
const [conflict, setConflict] = useState<{ row: SeoAutoApplyRow; fields: string[] } | null>(null)
|
|
232
|
+
const [mutation, setMutation] = useState<Mutation | null>(null)
|
|
233
|
+
/** The one line that changes after a revert — announced via `role="status"`. */
|
|
234
|
+
const [lastReverted, setLastReverted] = useState<string | null>(null)
|
|
235
|
+
/** Mirrors `mutation` synchronously so a second click in the same tick cannot slip past. */
|
|
236
|
+
const mutationRef = useRef<Mutation | null>(null)
|
|
237
|
+
const mountedRef = useRef(true)
|
|
238
|
+
/** Ignore responses from a superseded request (filter/page changed mid-flight). */
|
|
239
|
+
const requestRef = useRef(0)
|
|
240
|
+
const busy = mutation !== null
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
mountedRef.current = true
|
|
244
|
+
return () => {
|
|
245
|
+
mountedRef.current = false
|
|
246
|
+
}
|
|
247
|
+
}, [])
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `silent` re-reads keep the table on screen (post-revert / parent refresh);
|
|
251
|
+
* the initial load and filter or page changes show the loading state.
|
|
252
|
+
*/
|
|
253
|
+
const load = useCallback(
|
|
254
|
+
async (silent: boolean) => {
|
|
255
|
+
const requestId = ++requestRef.current
|
|
256
|
+
if (!silent) {
|
|
257
|
+
setLoading(true)
|
|
258
|
+
setLastReverted(null)
|
|
259
|
+
}
|
|
260
|
+
setError(null)
|
|
261
|
+
try {
|
|
262
|
+
const next = await fetchAutopilotApplied({
|
|
263
|
+
page,
|
|
264
|
+
pageSize: PAGE_SIZE,
|
|
265
|
+
status: status || undefined,
|
|
266
|
+
outcome: outcome || undefined,
|
|
267
|
+
})
|
|
268
|
+
if (!mountedRef.current || requestId !== requestRef.current) return
|
|
269
|
+
setData(next)
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (!mountedRef.current || requestId !== requestRef.current) return
|
|
272
|
+
setError(
|
|
273
|
+
err instanceof Error && err.message ? err.message : 'Failed to load autopilot history.',
|
|
274
|
+
)
|
|
275
|
+
} finally {
|
|
276
|
+
if (mountedRef.current && requestId === requestRef.current) setLoading(false)
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
[page, status, outcome],
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
void load(false)
|
|
284
|
+
}, [load])
|
|
285
|
+
|
|
286
|
+
const seenRefreshKeyRef = useRef(refreshKey)
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (seenRefreshKeyRef.current === refreshKey) return
|
|
289
|
+
seenRefreshKeyRef.current = refreshKey
|
|
290
|
+
void load(true)
|
|
291
|
+
}, [load, refreshKey])
|
|
292
|
+
|
|
293
|
+
// A filtered refetch can land on a page that no longer exists (e.g. the
|
|
294
|
+
// only `applied` row on page 3 was just reverted). Snap back to the last page.
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!data || data.docs.length > 0 || page <= 1) return
|
|
297
|
+
setPage(Math.max(1, Math.ceil(data.total / data.pageSize)))
|
|
298
|
+
}, [data, page])
|
|
299
|
+
|
|
300
|
+
const beginMutation = useCallback((kind: Mutation): boolean => {
|
|
301
|
+
if (mutationRef.current !== null) return false
|
|
302
|
+
mutationRef.current = kind
|
|
303
|
+
setMutation(kind)
|
|
304
|
+
return true
|
|
305
|
+
}, [])
|
|
306
|
+
const endMutation = useCallback(() => {
|
|
307
|
+
mutationRef.current = null
|
|
308
|
+
if (mountedRef.current) setMutation(null)
|
|
309
|
+
}, [])
|
|
310
|
+
|
|
311
|
+
const doRevert = useCallback(
|
|
312
|
+
async (row: SeoAutoApplyRow, force: boolean) => {
|
|
313
|
+
if (!beginMutation('revert')) return
|
|
314
|
+
try {
|
|
315
|
+
const res = await revertAutopilotChange(row.id, force)
|
|
316
|
+
if (!mountedRef.current) return
|
|
317
|
+
if (res.ok) {
|
|
318
|
+
toast.success('Change reverted.')
|
|
319
|
+
setLastReverted(displayTitle(row))
|
|
320
|
+
onChanged?.()
|
|
321
|
+
await load(true)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (res.conflictFields !== undefined) {
|
|
325
|
+
setConflict({ row, fields: res.conflictFields })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
toast.error(res.error)
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (mountedRef.current) {
|
|
331
|
+
toast.error(err instanceof Error && err.message ? err.message : 'Request failed.')
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
endMutation()
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
[beginMutation, endMutation, load, onChanged],
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
const rows = data?.docs ?? []
|
|
341
|
+
const total = data?.total ?? 0
|
|
342
|
+
const pageSize = data?.pageSize ?? PAGE_SIZE
|
|
343
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
344
|
+
const rangeStart = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
345
|
+
const rangeEnd = Math.min(total, page * pageSize)
|
|
346
|
+
const filtering = status !== '' || outcome !== ''
|
|
347
|
+
const statusId = `${baseId}-status`
|
|
348
|
+
const outcomeId = `${baseId}-outcome`
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<SectionCard
|
|
352
|
+
title="Applied by autopilot"
|
|
353
|
+
description="Every unattended change with its gate results and Search Console outcome. Revert restores the previous values and reopens the issue."
|
|
354
|
+
action={
|
|
355
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
356
|
+
<label htmlFor={statusId} className="sr-only">
|
|
357
|
+
Filter by status
|
|
358
|
+
</label>
|
|
359
|
+
<select
|
|
360
|
+
id={statusId}
|
|
361
|
+
className={SELECT_CLASS}
|
|
362
|
+
value={status}
|
|
363
|
+
disabled={busy}
|
|
364
|
+
onChange={(e) => {
|
|
365
|
+
const v = e.target.value
|
|
366
|
+
setStatus(isStatus(v) ? v : '')
|
|
367
|
+
setPage(1)
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
{STATUS_OPTIONS.map((o) => (
|
|
371
|
+
<option key={o.value} value={o.value}>
|
|
372
|
+
{o.label}
|
|
373
|
+
</option>
|
|
374
|
+
))}
|
|
375
|
+
</select>
|
|
376
|
+
<label htmlFor={outcomeId} className="sr-only">
|
|
377
|
+
Filter by outcome
|
|
378
|
+
</label>
|
|
379
|
+
<select
|
|
380
|
+
id={outcomeId}
|
|
381
|
+
className={SELECT_CLASS}
|
|
382
|
+
value={outcome}
|
|
383
|
+
disabled={busy}
|
|
384
|
+
onChange={(e) => {
|
|
385
|
+
const v = e.target.value
|
|
386
|
+
setOutcome(isOutcome(v) ? v : '')
|
|
387
|
+
setPage(1)
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
{OUTCOME_OPTIONS.map((o) => (
|
|
391
|
+
<option key={o.value} value={o.value}>
|
|
392
|
+
{o.label}
|
|
393
|
+
</option>
|
|
394
|
+
))}
|
|
395
|
+
</select>
|
|
396
|
+
</div>
|
|
397
|
+
}
|
|
398
|
+
>
|
|
399
|
+
{/* Always mounted so AT reliably announces the text change; empty (and unpadded) until a revert lands. */}
|
|
400
|
+
<p
|
|
401
|
+
className={`text-muted-foreground text-sm ${lastReverted && !loading ? 'mb-3' : ''}`}
|
|
402
|
+
role="status"
|
|
403
|
+
aria-live="polite"
|
|
404
|
+
>
|
|
405
|
+
{lastReverted && !loading ? `Reverted the change to “${lastReverted}”.` : ''}
|
|
406
|
+
</p>
|
|
407
|
+
{loading ? (
|
|
408
|
+
<SeoLoading label="Loading autopilot history…" />
|
|
409
|
+
) : error ? (
|
|
410
|
+
<SeoErrorState message={error} onRetry={() => void load(false)} />
|
|
411
|
+
) : rows.length === 0 ? (
|
|
412
|
+
<SeoEmptyState
|
|
413
|
+
icon={<Bot size={24} />}
|
|
414
|
+
title={
|
|
415
|
+
filtering ? 'No changes match these filters' : 'Autopilot has not applied anything yet'
|
|
416
|
+
}
|
|
417
|
+
description={
|
|
418
|
+
filtering
|
|
419
|
+
? 'Clear the status or outcome filter to see the full ledger.'
|
|
420
|
+
: 'Enable fix types in Settings → SEO → SEO Autopilot; changes show up here after the daily run.'
|
|
421
|
+
}
|
|
422
|
+
action={
|
|
423
|
+
filtering ? (
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
className={btnSecondary}
|
|
427
|
+
onClick={() => {
|
|
428
|
+
setStatus('')
|
|
429
|
+
setOutcome('')
|
|
430
|
+
setPage(1)
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
Clear filters
|
|
434
|
+
</button>
|
|
435
|
+
) : onNavigate ? (
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
className={btnSecondary}
|
|
439
|
+
onClick={() => onNavigate(SEO_SETTINGS_PATH)}
|
|
440
|
+
>
|
|
441
|
+
Open autopilot settings
|
|
442
|
+
</button>
|
|
443
|
+
) : undefined
|
|
444
|
+
}
|
|
445
|
+
/>
|
|
446
|
+
) : (
|
|
447
|
+
<div className="space-y-3">
|
|
448
|
+
<div className="border-border overflow-x-auto rounded-md border">
|
|
449
|
+
<table className="w-full text-sm" aria-label="Changes applied by autopilot">
|
|
450
|
+
<thead>
|
|
451
|
+
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left text-xs">
|
|
452
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
453
|
+
Change
|
|
454
|
+
</th>
|
|
455
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
456
|
+
Before → after
|
|
457
|
+
</th>
|
|
458
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
459
|
+
Quality
|
|
460
|
+
</th>
|
|
461
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
462
|
+
Applied on
|
|
463
|
+
</th>
|
|
464
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
465
|
+
Outcome
|
|
466
|
+
</th>
|
|
467
|
+
<th scope="col" className="px-3 py-2.5">
|
|
468
|
+
<span className="sr-only">Actions</span>
|
|
469
|
+
</th>
|
|
470
|
+
</tr>
|
|
471
|
+
</thead>
|
|
472
|
+
<tbody className="divide-border divide-y">
|
|
473
|
+
{rows.map((row) => {
|
|
474
|
+
const title = displayTitle(row)
|
|
475
|
+
const editPath =
|
|
476
|
+
row.entityId !== null
|
|
477
|
+
? sourceEditPath(row.collection ?? undefined, row.entityId)
|
|
478
|
+
: null
|
|
479
|
+
return (
|
|
480
|
+
<tr key={row.id} className="hover:bg-accent/20 align-top transition-colors">
|
|
481
|
+
<td className="px-3 py-3">
|
|
482
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
483
|
+
{editPath && onNavigate ? (
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
className="text-foreground focus-visible:ring-ring rounded-sm text-left font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
|
487
|
+
onClick={() => onNavigate(editPath)}
|
|
488
|
+
aria-label={`Open ${title} in editor`}
|
|
489
|
+
>
|
|
490
|
+
{title}
|
|
491
|
+
</button>
|
|
492
|
+
) : (
|
|
493
|
+
<span className="text-foreground font-medium">{title}</span>
|
|
494
|
+
)}
|
|
495
|
+
<KindBadge kind={row.kind} />
|
|
496
|
+
<StatusBadge status={row.status} />
|
|
497
|
+
</div>
|
|
498
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
499
|
+
<span>{issueTypeLabel(row.issueType)}</span>
|
|
500
|
+
{row.entityUrl && row.entityTitle ? (
|
|
501
|
+
<>
|
|
502
|
+
<span aria-hidden> · </span>
|
|
503
|
+
<span>{row.entityUrl}</span>
|
|
504
|
+
</>
|
|
505
|
+
) : null}
|
|
506
|
+
</p>
|
|
507
|
+
</td>
|
|
508
|
+
<td className="max-w-md px-3 py-3">
|
|
509
|
+
<Diff row={row} />
|
|
510
|
+
</td>
|
|
511
|
+
<td className="px-3 py-3">
|
|
512
|
+
<Quality gate={row.gate} />
|
|
513
|
+
</td>
|
|
514
|
+
<td className="text-muted-foreground px-3 py-3 text-xs whitespace-nowrap">
|
|
515
|
+
<AppliedTime iso={row.appliedAt} />
|
|
516
|
+
</td>
|
|
517
|
+
<td className="px-3 py-3">
|
|
518
|
+
<OutcomeBadge outcome={row.outcome} />
|
|
519
|
+
</td>
|
|
520
|
+
<td className="px-3 py-3 text-right">
|
|
521
|
+
{data?.canRevert && row.status === 'applied' ? (
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className={ROW_BUTTON_CLASS}
|
|
525
|
+
onClick={() => setTarget(row)}
|
|
526
|
+
disabled={busy}
|
|
527
|
+
aria-label={`Revert change to ${title}`}
|
|
528
|
+
>
|
|
529
|
+
<Undo2 className="h-4 w-4" aria-hidden />
|
|
530
|
+
Revert
|
|
531
|
+
</button>
|
|
532
|
+
) : null}
|
|
533
|
+
</td>
|
|
534
|
+
</tr>
|
|
535
|
+
)
|
|
536
|
+
})}
|
|
537
|
+
</tbody>
|
|
538
|
+
</table>
|
|
539
|
+
</div>
|
|
540
|
+
{total > pageSize ? (
|
|
541
|
+
<nav
|
|
542
|
+
className="flex items-center justify-between gap-3 text-sm"
|
|
543
|
+
aria-label="Autopilot history pagination"
|
|
544
|
+
>
|
|
545
|
+
<p className="text-muted-foreground">
|
|
546
|
+
{rangeStart}–{rangeEnd} of {total}
|
|
547
|
+
</p>
|
|
548
|
+
<div className="flex gap-2">
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
className={PAGER_BUTTON_CLASS}
|
|
552
|
+
disabled={page <= 1 || busy}
|
|
553
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
554
|
+
>
|
|
555
|
+
Previous
|
|
556
|
+
</button>
|
|
557
|
+
<button
|
|
558
|
+
type="button"
|
|
559
|
+
className={PAGER_BUTTON_CLASS}
|
|
560
|
+
disabled={page >= totalPages || busy}
|
|
561
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
562
|
+
>
|
|
563
|
+
Next
|
|
564
|
+
</button>
|
|
565
|
+
</div>
|
|
566
|
+
</nav>
|
|
567
|
+
) : null}
|
|
568
|
+
</div>
|
|
569
|
+
)}
|
|
570
|
+
|
|
571
|
+
<ConfirmDialog
|
|
572
|
+
open={target !== null}
|
|
573
|
+
onClose={() => setTarget(null)}
|
|
574
|
+
onConfirm={() => {
|
|
575
|
+
if (target) void doRevert(target, false)
|
|
576
|
+
}}
|
|
577
|
+
title="Revert this autopilot change?"
|
|
578
|
+
description="The previous values are restored (a new version is created) and the issue is reopened. Autopilot will not touch this issue again for 30 days."
|
|
579
|
+
confirmLabel="Revert change"
|
|
580
|
+
destructive
|
|
581
|
+
/>
|
|
582
|
+
<ConfirmDialog
|
|
583
|
+
open={conflict !== null}
|
|
584
|
+
onClose={() => setConflict(null)}
|
|
585
|
+
onConfirm={() => {
|
|
586
|
+
if (conflict) void doRevert(conflict.row, true)
|
|
587
|
+
}}
|
|
588
|
+
title="Someone edited this since"
|
|
589
|
+
description={`These fields were changed after autopilot applied them: ${
|
|
590
|
+
conflict?.fields.join(', ') ?? ''
|
|
591
|
+
}. Reverting anyway overwrites those edits.`}
|
|
592
|
+
confirmLabel="Revert anyway"
|
|
593
|
+
destructive
|
|
594
|
+
/>
|
|
595
|
+
</SectionCard>
|
|
596
|
+
)
|
|
597
|
+
}
|