@prenta/admin 1.13.0 → 1.14.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 +21 -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 +71 -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/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 +292 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
- 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/seo-service.d.ts +87 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +36 -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/ProposalsTab.d.ts +7 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
- package/dist/views/seo/ProposalsTab.js +298 -0
- package/dist/views/seo/ProposalsTab.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/lib/seo-service-proposals.test.ts +79 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +370 -0
- package/src/components/seo/ChangePreview.tsx +47 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
- package/src/lib/seo-service.ts +125 -0
- package/src/views/SEO.tsx +40 -7
- package/src/views/seo/ProposalsTab.tsx +681 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SEO → Proposals: every cached AI issue fix and open 404-redirect suggestion
|
|
5
|
+
* in one review surface. Batch approve honours server governance
|
|
6
|
+
* (`allowBulkApply`); the UI only pre-disables the button using the flags the
|
|
7
|
+
* list payload exposes. Generation is human-triggered, capped per pass, and
|
|
8
|
+
* loops client-side while the server reports `remaining > 0`.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
11
|
+
import {
|
|
12
|
+
ArrowRightLeft,
|
|
13
|
+
Check,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
Inbox,
|
|
16
|
+
Loader2,
|
|
17
|
+
Sparkles,
|
|
18
|
+
Square,
|
|
19
|
+
X,
|
|
20
|
+
} from 'lucide-react'
|
|
21
|
+
import { toast } from 'sonner'
|
|
22
|
+
import {
|
|
23
|
+
bulkApplySeoProposals,
|
|
24
|
+
dismissRedirectSuggestion,
|
|
25
|
+
dismissSeoIssueFix,
|
|
26
|
+
fetchSeoProposals,
|
|
27
|
+
generateSeoProposals,
|
|
28
|
+
type SeoProposal,
|
|
29
|
+
type SeoProposalBulkApplyItem,
|
|
30
|
+
} from '../../lib/seo-service.js'
|
|
31
|
+
import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
32
|
+
import { brandAlignmentLabel, brandAlignmentTone } from '../../lib/brand-voice-client.js'
|
|
33
|
+
import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
|
|
34
|
+
import { ChangePreview } from '../../components/seo/ChangePreview.js'
|
|
35
|
+
import {
|
|
36
|
+
SectionCard,
|
|
37
|
+
SeoEmptyState,
|
|
38
|
+
SeoErrorState,
|
|
39
|
+
SeoLoading,
|
|
40
|
+
SeoStatusBadge,
|
|
41
|
+
btnPrimary,
|
|
42
|
+
btnSecondary,
|
|
43
|
+
} from '../../components/seo/primitives.js'
|
|
44
|
+
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
45
|
+
import { useSeoResource } from './useSeoResource.js'
|
|
46
|
+
|
|
47
|
+
const GENERATE_BATCH_SIZE = 10
|
|
48
|
+
const AI_SETTINGS_PATH = '/settings?tab=ai'
|
|
49
|
+
/** Stable empty list so memoised derivations don't recompute while loading. */
|
|
50
|
+
const NO_PROPOSALS: SeoProposal[] = []
|
|
51
|
+
|
|
52
|
+
/** The one mutation allowed in flight at a time; every control is disabled while set. */
|
|
53
|
+
type Mutation = 'apply' | 'dismiss' | 'generate'
|
|
54
|
+
|
|
55
|
+
function errorMessage(err: unknown): string {
|
|
56
|
+
return err instanceof Error && err.message ? err.message : 'Request failed.'
|
|
57
|
+
}
|
|
58
|
+
|
|
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
|
+
function confidenceTone(c: number): 'good' | 'fair' | 'poor' {
|
|
71
|
+
return c >= 0.7 ? 'good' : c >= 0.4 ? 'fair' : 'poor'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function plural(count: number, noun: string): string {
|
|
75
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toBulkItem(p: SeoProposal): SeoProposalBulkApplyItem {
|
|
79
|
+
return p.kind === 'redirect'
|
|
80
|
+
? { kind: 'redirect', id: p.suggestionId ?? '' }
|
|
81
|
+
: { kind: 'issue-fix', id: p.issueId ?? '', fingerprint: p.fingerprint ?? undefined }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function BrandScoreBadge({ score }: { score: number }) {
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className="border-border bg-muted/40 inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs"
|
|
88
|
+
aria-label={`Brand voice score ${score} out of 100`}
|
|
89
|
+
>
|
|
90
|
+
<span className={`font-medium ${brandAlignmentTone(score)}`}>{score}/100</span>
|
|
91
|
+
<span className="text-muted-foreground">{brandAlignmentLabel(score)}</span>
|
|
92
|
+
</span>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function SourceBadge({ source }: { source: SeoProposal['source'] }) {
|
|
97
|
+
switch (source) {
|
|
98
|
+
case 'ai':
|
|
99
|
+
return (
|
|
100
|
+
<span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
|
|
101
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
102
|
+
AI
|
|
103
|
+
</span>
|
|
104
|
+
)
|
|
105
|
+
case 'ai-404-recovery':
|
|
106
|
+
return (
|
|
107
|
+
<span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
|
|
108
|
+
<ArrowRightLeft className="h-3 w-3" aria-hidden />
|
|
109
|
+
404 recovery
|
|
110
|
+
</span>
|
|
111
|
+
)
|
|
112
|
+
case 'deterministic':
|
|
113
|
+
return (
|
|
114
|
+
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
|
115
|
+
Rule-based
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
118
|
+
default: {
|
|
119
|
+
const _exhaustive: never = source
|
|
120
|
+
return _exhaustive
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ProposalsTab({
|
|
126
|
+
planInfo,
|
|
127
|
+
onChanged,
|
|
128
|
+
onNavigate,
|
|
129
|
+
}: {
|
|
130
|
+
planInfo: PlanInfo | null
|
|
131
|
+
onChanged?: () => void
|
|
132
|
+
onNavigate?: (path: string) => void
|
|
133
|
+
}) {
|
|
134
|
+
const { data, loading, error, refetch } = useSeoResource(fetchSeoProposals, [])
|
|
135
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
136
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
137
|
+
const [confirmApply, setConfirmApply] = useState(false)
|
|
138
|
+
const [confirmGenerate, setConfirmGenerate] = useState(false)
|
|
139
|
+
const [mutation, setMutation] = useState<Mutation | null>(null)
|
|
140
|
+
const [pendingItems, setPendingItems] = useState<SeoProposalBulkApplyItem[]>([])
|
|
141
|
+
const [generating, setGenerating] = useState<{ done: number; total: number } | null>(null)
|
|
142
|
+
const stopRef = useRef(false)
|
|
143
|
+
/** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
|
|
144
|
+
const mutationRef = useRef<Mutation | null>(null)
|
|
145
|
+
const mountedRef = useRef(true)
|
|
146
|
+
const busy = mutation !== null
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
mountedRef.current = true
|
|
150
|
+
return () => {
|
|
151
|
+
mountedRef.current = false
|
|
152
|
+
stopRef.current = true
|
|
153
|
+
}
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
/** Claims the mutation slot; returns false when another mutation is already running. */
|
|
157
|
+
const beginMutation = useCallback((kind: Mutation): boolean => {
|
|
158
|
+
if (mutationRef.current !== null) return false
|
|
159
|
+
mutationRef.current = kind
|
|
160
|
+
setMutation(kind)
|
|
161
|
+
return true
|
|
162
|
+
}, [])
|
|
163
|
+
const endMutation = useCallback(() => {
|
|
164
|
+
mutationRef.current = null
|
|
165
|
+
if (mountedRef.current) setMutation(null)
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
|
|
169
|
+
const proposals = data?.proposals ?? NO_PROPOSALS
|
|
170
|
+
const live = useMemo(() => proposals.filter((p) => !p.stale), [proposals])
|
|
171
|
+
const selectedProposals = useMemo(() => live.filter((p) => selected.has(p.id)), [live, selected])
|
|
172
|
+
|
|
173
|
+
const bulkBlockedReason = useMemo(() => {
|
|
174
|
+
if (!data || selectedProposals.length <= 1) return null
|
|
175
|
+
const hasIssue = selectedProposals.some((p) => p.kind === 'issue-fix')
|
|
176
|
+
const hasRedirect = selectedProposals.some((p) => p.kind === 'redirect')
|
|
177
|
+
if (hasIssue && !data.governance.issueFixBulk) return 'SEO metadata generation'
|
|
178
|
+
if (hasRedirect && !data.governance.redirectBulk) return '404 recovery'
|
|
179
|
+
return null
|
|
180
|
+
}, [data, selectedProposals])
|
|
181
|
+
|
|
182
|
+
const allSelected = live.length > 0 && selected.size === live.length
|
|
183
|
+
const toggleSelectAll = useCallback(() => {
|
|
184
|
+
setSelected((cur) => (cur.size === live.length ? new Set() : new Set(live.map((p) => p.id))))
|
|
185
|
+
}, [live])
|
|
186
|
+
const toggleSelect = useCallback((id: string) => {
|
|
187
|
+
setSelected((cur) => {
|
|
188
|
+
const next = new Set(cur)
|
|
189
|
+
if (next.has(id)) next.delete(id)
|
|
190
|
+
else next.add(id)
|
|
191
|
+
return next
|
|
192
|
+
})
|
|
193
|
+
}, [])
|
|
194
|
+
const toggleExpanded = useCallback((id: string) => {
|
|
195
|
+
setExpanded((cur) => {
|
|
196
|
+
const next = new Set(cur)
|
|
197
|
+
if (next.has(id)) next.delete(id)
|
|
198
|
+
else next.add(id)
|
|
199
|
+
return next
|
|
200
|
+
})
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
203
|
+
const afterMutation = useCallback(() => {
|
|
204
|
+
setSelected(new Set())
|
|
205
|
+
refetch()
|
|
206
|
+
onChanged?.()
|
|
207
|
+
}, [refetch, onChanged])
|
|
208
|
+
|
|
209
|
+
const runApply = useCallback(
|
|
210
|
+
async (items: SeoProposalBulkApplyItem[]) => {
|
|
211
|
+
if (items.length === 0) return
|
|
212
|
+
if (!beginMutation('apply')) return
|
|
213
|
+
try {
|
|
214
|
+
const res = await bulkApplySeoProposals(items)
|
|
215
|
+
if (!mountedRef.current) return
|
|
216
|
+
const result = res.result
|
|
217
|
+
if (res.error || !result) {
|
|
218
|
+
toast.error(res.error ?? 'Unexpected response from the server.')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
if (result.applied > 0) {
|
|
222
|
+
toast.success(`${plural(result.applied, 'proposal')} applied.`)
|
|
223
|
+
}
|
|
224
|
+
if (result.failed > 0) {
|
|
225
|
+
const reasons = result.results
|
|
226
|
+
.filter((r) => r.status !== 'applied' && r.status !== 'pending')
|
|
227
|
+
.map((r) => `${r.id}: ${r.reason ?? r.status}`)
|
|
228
|
+
.slice(0, 3)
|
|
229
|
+
.join('; ')
|
|
230
|
+
toast.error(`${result.failed} could not be applied. ${reasons}`)
|
|
231
|
+
}
|
|
232
|
+
const pendingIds = new Set(
|
|
233
|
+
result.results.filter((r) => r.status === 'pending').map((r) => r.id),
|
|
234
|
+
)
|
|
235
|
+
setPendingItems(items.filter((i) => pendingIds.has(i.id)))
|
|
236
|
+
afterMutation()
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (mountedRef.current) toast.error(errorMessage(err))
|
|
239
|
+
} finally {
|
|
240
|
+
endMutation()
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
[afterMutation, beginMutation, endMutation],
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const handleDismissSelected = useCallback(async () => {
|
|
247
|
+
const targets = selectedProposals
|
|
248
|
+
if (targets.length === 0) return
|
|
249
|
+
if (!beginMutation('dismiss')) return
|
|
250
|
+
try {
|
|
251
|
+
const results = await Promise.allSettled(
|
|
252
|
+
targets.map((p) =>
|
|
253
|
+
p.kind === 'redirect'
|
|
254
|
+
? dismissRedirectSuggestion(p.suggestionId ?? '')
|
|
255
|
+
: dismissSeoIssueFix(p.issueId ?? ''),
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
if (!mountedRef.current) return
|
|
259
|
+
const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
|
|
260
|
+
if (failed > 0) toast.error(`${failed} of ${targets.length} could not be dismissed.`)
|
|
261
|
+
else toast.success(`${plural(targets.length, 'proposal')} dismissed.`)
|
|
262
|
+
afterMutation()
|
|
263
|
+
} finally {
|
|
264
|
+
endMutation()
|
|
265
|
+
}
|
|
266
|
+
}, [selectedProposals, afterMutation, beginMutation, endMutation])
|
|
267
|
+
|
|
268
|
+
const handleDismissOne = useCallback(
|
|
269
|
+
async (p: SeoProposal) => {
|
|
270
|
+
if (!beginMutation('dismiss')) return
|
|
271
|
+
try {
|
|
272
|
+
const res =
|
|
273
|
+
p.kind === 'redirect'
|
|
274
|
+
? await dismissRedirectSuggestion(p.suggestionId ?? '')
|
|
275
|
+
: await dismissSeoIssueFix(p.issueId ?? '')
|
|
276
|
+
if (!mountedRef.current) return
|
|
277
|
+
if (res.error) {
|
|
278
|
+
toast.error(res.error)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
afterMutation()
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (mountedRef.current) toast.error(errorMessage(err))
|
|
284
|
+
} finally {
|
|
285
|
+
endMutation()
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[afterMutation, beginMutation, endMutation],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const handleGenerate = useCallback(async () => {
|
|
292
|
+
if (!beginMutation('generate')) return
|
|
293
|
+
const eligible = data?.counts.eligibleForGeneration ?? 0
|
|
294
|
+
stopRef.current = false
|
|
295
|
+
let total = 0
|
|
296
|
+
let done = 0
|
|
297
|
+
let transportError = false
|
|
298
|
+
setGenerating({ done: 0, total: eligible })
|
|
299
|
+
try {
|
|
300
|
+
for (;;) {
|
|
301
|
+
const res = await generateSeoProposals()
|
|
302
|
+
// The tab may have unmounted mid-pass; stop issuing requests and stay silent.
|
|
303
|
+
if (!mountedRef.current) return
|
|
304
|
+
const r = res.result
|
|
305
|
+
if (res.error || !r) {
|
|
306
|
+
toast.error(res.error ?? 'Unexpected response from the server.')
|
|
307
|
+
transportError = true
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
total += r.generated
|
|
311
|
+
done += r.generated + r.skipped + r.failed
|
|
312
|
+
setGenerating({ done, total: eligible })
|
|
313
|
+
if (r.haltedReason) {
|
|
314
|
+
toast.error(`Generation stopped: ${r.haltedReason}`)
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
if (r.failed > 0) {
|
|
318
|
+
toast.error(`${plural(r.failed, 'issue')} failed to generate.`)
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
if (r.remaining <= 0 || stopRef.current) break
|
|
322
|
+
}
|
|
323
|
+
if (!transportError) toast.success(`${plural(total, 'proposal')} generated.`)
|
|
324
|
+
afterMutation()
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (mountedRef.current) toast.error(errorMessage(err))
|
|
327
|
+
} finally {
|
|
328
|
+
if (mountedRef.current) setGenerating(null)
|
|
329
|
+
endMutation()
|
|
330
|
+
}
|
|
331
|
+
}, [data, afterMutation, beginMutation, endMutation])
|
|
332
|
+
|
|
333
|
+
const eligible = data?.counts.eligibleForGeneration ?? 0
|
|
334
|
+
|
|
335
|
+
const generateAction = inlineApplyEnabled ? (
|
|
336
|
+
generating ? (
|
|
337
|
+
<div className="flex items-center gap-2">
|
|
338
|
+
<span
|
|
339
|
+
className="text-muted-foreground flex items-center gap-2 text-sm"
|
|
340
|
+
role="status"
|
|
341
|
+
aria-live="polite"
|
|
342
|
+
>
|
|
343
|
+
<Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden />
|
|
344
|
+
Generating… {generating.done} of {generating.total}
|
|
345
|
+
</span>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className={btnSecondary}
|
|
349
|
+
onClick={() => {
|
|
350
|
+
stopRef.current = true
|
|
351
|
+
}}
|
|
352
|
+
>
|
|
353
|
+
<Square className="h-4 w-4" aria-hidden />
|
|
354
|
+
Stop
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
) : (
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
className={btnPrimary}
|
|
361
|
+
onClick={() => setConfirmGenerate(true)}
|
|
362
|
+
disabled={eligible === 0 || loading || busy}
|
|
363
|
+
>
|
|
364
|
+
<Sparkles className="h-4 w-4" aria-hidden />
|
|
365
|
+
Generate proposals ({eligible} eligible)
|
|
366
|
+
</button>
|
|
367
|
+
)
|
|
368
|
+
) : null
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<div className="space-y-6">
|
|
372
|
+
{!inlineApplyEnabled ? (
|
|
373
|
+
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
374
|
+
) : null}
|
|
375
|
+
|
|
376
|
+
<SectionCard
|
|
377
|
+
title="Proposals"
|
|
378
|
+
description="Review, approve, or dismiss what the AI proposes across issue fixes and 404 redirects."
|
|
379
|
+
action={generateAction}
|
|
380
|
+
>
|
|
381
|
+
{loading ? (
|
|
382
|
+
<SeoLoading />
|
|
383
|
+
) : error ? (
|
|
384
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
385
|
+
) : proposals.length === 0 ? (
|
|
386
|
+
<SeoEmptyState
|
|
387
|
+
icon={<Inbox size={24} />}
|
|
388
|
+
title="No proposals yet"
|
|
389
|
+
description="Generate proposals to draft fixes for open, fixable issues, or run 404 recovery from the Redirects tab."
|
|
390
|
+
/>
|
|
391
|
+
) : (
|
|
392
|
+
<div className="space-y-3">
|
|
393
|
+
{pendingItems.length > 0 && (
|
|
394
|
+
<div
|
|
395
|
+
className="border-border bg-muted/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
396
|
+
role="status"
|
|
397
|
+
aria-live="polite"
|
|
398
|
+
>
|
|
399
|
+
<span className="text-foreground">
|
|
400
|
+
{plural(pendingItems.length, 'proposal')} not reached within the time budget.
|
|
401
|
+
</span>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
405
|
+
onClick={() => void runApply(pendingItems)}
|
|
406
|
+
disabled={busy}
|
|
407
|
+
>
|
|
408
|
+
Apply remaining ({pendingItems.length})
|
|
409
|
+
</button>
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
413
|
+
onClick={() => setPendingItems([])}
|
|
414
|
+
>
|
|
415
|
+
Dismiss
|
|
416
|
+
</button>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{selected.size > 0 && (
|
|
421
|
+
<div
|
|
422
|
+
className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
423
|
+
role="region"
|
|
424
|
+
aria-label="Bulk actions"
|
|
425
|
+
>
|
|
426
|
+
<span className="text-foreground font-medium">{selected.size} selected</span>
|
|
427
|
+
<span className="bg-border mx-1 h-4 w-px" aria-hidden />
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
className="text-foreground hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
431
|
+
onClick={() => setConfirmApply(true)}
|
|
432
|
+
disabled={busy || bulkBlockedReason !== null}
|
|
433
|
+
>
|
|
434
|
+
Approve selected
|
|
435
|
+
</button>
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
className="text-foreground hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
439
|
+
onClick={() => void handleDismissSelected()}
|
|
440
|
+
disabled={busy}
|
|
441
|
+
>
|
|
442
|
+
Dismiss selected
|
|
443
|
+
</button>
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto rounded-md px-2 py-1 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
447
|
+
onClick={() => setSelected(new Set())}
|
|
448
|
+
>
|
|
449
|
+
Clear
|
|
450
|
+
</button>
|
|
451
|
+
{bulkBlockedReason && (
|
|
452
|
+
<p className="text-muted-foreground basis-full text-sm" role="status">
|
|
453
|
+
Applying several {bulkBlockedReason} proposals at once needs “Allow bulk apply”.
|
|
454
|
+
{onNavigate ? (
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
onClick={() => onNavigate(AI_SETTINGS_PATH)}
|
|
458
|
+
className="text-primary focus-visible:ring-ring ml-1 rounded-sm font-medium hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
|
459
|
+
>
|
|
460
|
+
Open Settings → AI
|
|
461
|
+
</button>
|
|
462
|
+
) : null}
|
|
463
|
+
</p>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
<div className="border-border overflow-x-auto rounded-md border">
|
|
469
|
+
<table className="w-full text-sm" aria-label="AI proposals">
|
|
470
|
+
<thead>
|
|
471
|
+
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left text-xs">
|
|
472
|
+
<th scope="col" className="w-10 px-3 py-2.5">
|
|
473
|
+
<input
|
|
474
|
+
type="checkbox"
|
|
475
|
+
checked={allSelected}
|
|
476
|
+
onChange={toggleSelectAll}
|
|
477
|
+
disabled={live.length === 0}
|
|
478
|
+
aria-label="Select all proposals"
|
|
479
|
+
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
480
|
+
/>
|
|
481
|
+
</th>
|
|
482
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
483
|
+
Proposal
|
|
484
|
+
</th>
|
|
485
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
486
|
+
Change
|
|
487
|
+
</th>
|
|
488
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
489
|
+
Signals
|
|
490
|
+
</th>
|
|
491
|
+
<th scope="col" className="px-3 py-2.5 font-medium">
|
|
492
|
+
Age
|
|
493
|
+
</th>
|
|
494
|
+
<th scope="col" className="px-3 py-2.5">
|
|
495
|
+
<span className="sr-only">Actions</span>
|
|
496
|
+
</th>
|
|
497
|
+
</tr>
|
|
498
|
+
</thead>
|
|
499
|
+
<tbody className="divide-border divide-y">
|
|
500
|
+
{proposals.map((p) => {
|
|
501
|
+
const first = p.changes[0]
|
|
502
|
+
return (
|
|
503
|
+
<ProposalRow
|
|
504
|
+
key={p.id}
|
|
505
|
+
proposal={p}
|
|
506
|
+
selected={selected.has(p.id)}
|
|
507
|
+
expanded={expanded.has(p.id)}
|
|
508
|
+
summary={
|
|
509
|
+
first
|
|
510
|
+
? `${String(first.before ?? '—')} → ${String(first.after ?? '—')}`
|
|
511
|
+
: p.justification
|
|
512
|
+
}
|
|
513
|
+
onToggleSelect={() => toggleSelect(p.id)}
|
|
514
|
+
onToggleExpanded={() => toggleExpanded(p.id)}
|
|
515
|
+
onApprove={() => void runApply([toBulkItem(p)])}
|
|
516
|
+
onDismiss={() => void handleDismissOne(p)}
|
|
517
|
+
busy={busy}
|
|
518
|
+
canApply={inlineApplyEnabled || p.kind === 'redirect'}
|
|
519
|
+
/>
|
|
520
|
+
)
|
|
521
|
+
})}
|
|
522
|
+
</tbody>
|
|
523
|
+
</table>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
)}
|
|
527
|
+
</SectionCard>
|
|
528
|
+
|
|
529
|
+
<ConfirmDialog
|
|
530
|
+
open={confirmApply}
|
|
531
|
+
onClose={() => setConfirmApply(false)}
|
|
532
|
+
onConfirm={() => void runApply(selectedProposals.map(toBulkItem))}
|
|
533
|
+
title={`Apply ${plural(selectedProposals.length, 'proposal')}?`}
|
|
534
|
+
description="Approved fixes write SEO fields, content links, or redirects immediately and mark their issues resolved. Each document keeps a version you can restore."
|
|
535
|
+
confirmLabel={`Apply ${plural(selectedProposals.length, 'proposal')}`}
|
|
536
|
+
/>
|
|
537
|
+
<ConfirmDialog
|
|
538
|
+
open={confirmGenerate}
|
|
539
|
+
onClose={() => setConfirmGenerate(false)}
|
|
540
|
+
onConfirm={() => void handleGenerate()}
|
|
541
|
+
title={`Generate proposals for ${plural(eligible, 'eligible issue')}?`}
|
|
542
|
+
description={`Runs in passes of ${GENERATE_BATCH_SIZE}. AI tokens are spent for each issue; you can stop between passes.`}
|
|
543
|
+
confirmLabel="Generate"
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function ProposalRow({
|
|
550
|
+
proposal: p,
|
|
551
|
+
selected,
|
|
552
|
+
expanded,
|
|
553
|
+
summary,
|
|
554
|
+
onToggleSelect,
|
|
555
|
+
onToggleExpanded,
|
|
556
|
+
onApprove,
|
|
557
|
+
onDismiss,
|
|
558
|
+
busy,
|
|
559
|
+
canApply,
|
|
560
|
+
}: {
|
|
561
|
+
proposal: SeoProposal
|
|
562
|
+
selected: boolean
|
|
563
|
+
expanded: boolean
|
|
564
|
+
summary: string
|
|
565
|
+
onToggleSelect: () => void
|
|
566
|
+
onToggleExpanded: () => void
|
|
567
|
+
onApprove: () => void
|
|
568
|
+
onDismiss: () => void
|
|
569
|
+
busy: boolean
|
|
570
|
+
canApply: boolean
|
|
571
|
+
}) {
|
|
572
|
+
return (
|
|
573
|
+
<>
|
|
574
|
+
<tr className={`${selected ? 'bg-accent/30' : ''} hover:bg-accent/20 transition-colors`}>
|
|
575
|
+
<td className="px-3 py-3 align-top">
|
|
576
|
+
{p.stale ? null : (
|
|
577
|
+
<input
|
|
578
|
+
type="checkbox"
|
|
579
|
+
checked={selected}
|
|
580
|
+
onChange={onToggleSelect}
|
|
581
|
+
aria-label={`Select proposal ${p.title}`}
|
|
582
|
+
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
583
|
+
/>
|
|
584
|
+
)}
|
|
585
|
+
</td>
|
|
586
|
+
<td className="px-3 py-3 align-top">
|
|
587
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
588
|
+
<span className="text-foreground font-medium">{p.title}</span>
|
|
589
|
+
{p.severity ? (
|
|
590
|
+
<SeoStatusBadge
|
|
591
|
+
label={p.severity}
|
|
592
|
+
tone={
|
|
593
|
+
p.severity === 'critical'
|
|
594
|
+
? 'critical'
|
|
595
|
+
: p.severity === 'warning'
|
|
596
|
+
? 'fair'
|
|
597
|
+
: 'neutral'
|
|
598
|
+
}
|
|
599
|
+
/>
|
|
600
|
+
) : null}
|
|
601
|
+
{p.stale ? <SeoStatusBadge label="Stale — regenerate" tone="poor" /> : null}
|
|
602
|
+
</div>
|
|
603
|
+
{p.entity.title || p.entity.url ? (
|
|
604
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
605
|
+
{p.entity.title ?? ''}
|
|
606
|
+
{p.entity.title && p.entity.url ? ' · ' : ''}
|
|
607
|
+
{p.entity.url ?? ''}
|
|
608
|
+
</p>
|
|
609
|
+
) : null}
|
|
610
|
+
</td>
|
|
611
|
+
<td className="px-3 py-3 align-top">
|
|
612
|
+
<p className="text-foreground max-w-md truncate">{summary}</p>
|
|
613
|
+
<button
|
|
614
|
+
type="button"
|
|
615
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring mt-1 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs focus-visible:ring-2 focus-visible:outline-none"
|
|
616
|
+
onClick={onToggleExpanded}
|
|
617
|
+
aria-expanded={expanded}
|
|
618
|
+
aria-label={`${expanded ? 'Hide' : 'Show'} changes for ${p.title}`}
|
|
619
|
+
>
|
|
620
|
+
{expanded ? 'Hide changes' : 'Show changes'}
|
|
621
|
+
<ChevronDown
|
|
622
|
+
className={`h-3 w-3 motion-safe:transition-transform ${expanded ? 'rotate-180' : ''}`}
|
|
623
|
+
aria-hidden
|
|
624
|
+
/>
|
|
625
|
+
</button>
|
|
626
|
+
</td>
|
|
627
|
+
<td className="px-3 py-3 align-top">
|
|
628
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
629
|
+
<SourceBadge source={p.source} />
|
|
630
|
+
{typeof p.confidence === 'number' ? (
|
|
631
|
+
<SeoStatusBadge
|
|
632
|
+
label={`${Math.round(p.confidence * 100)}%`}
|
|
633
|
+
tone={confidenceTone(p.confidence)}
|
|
634
|
+
/>
|
|
635
|
+
) : null}
|
|
636
|
+
{typeof p.brandAlignment === 'number' ? (
|
|
637
|
+
<BrandScoreBadge score={p.brandAlignment} />
|
|
638
|
+
) : null}
|
|
639
|
+
</div>
|
|
640
|
+
</td>
|
|
641
|
+
<td className="text-muted-foreground px-3 py-3 align-top text-xs whitespace-nowrap">
|
|
642
|
+
{relativeAge(p.generatedAt)}
|
|
643
|
+
</td>
|
|
644
|
+
<td className="px-3 py-3 align-top">
|
|
645
|
+
<div className="flex items-center justify-end gap-1">
|
|
646
|
+
{!p.stale && canApply ? (
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
onClick={onApprove}
|
|
650
|
+
disabled={busy}
|
|
651
|
+
className="text-success hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
652
|
+
aria-label={`Approve ${p.title}`}
|
|
653
|
+
>
|
|
654
|
+
<Check className="h-4 w-4" aria-hidden />
|
|
655
|
+
</button>
|
|
656
|
+
) : null}
|
|
657
|
+
<button
|
|
658
|
+
type="button"
|
|
659
|
+
onClick={onDismiss}
|
|
660
|
+
disabled={busy}
|
|
661
|
+
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring rounded-md p-1.5 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
662
|
+
aria-label={`Dismiss ${p.title}`}
|
|
663
|
+
>
|
|
664
|
+
<X className="h-4 w-4" aria-hidden />
|
|
665
|
+
</button>
|
|
666
|
+
</div>
|
|
667
|
+
</td>
|
|
668
|
+
</tr>
|
|
669
|
+
{expanded ? (
|
|
670
|
+
<tr>
|
|
671
|
+
<td colSpan={6} className="bg-muted/30 px-4 py-4">
|
|
672
|
+
<div className="space-y-3">
|
|
673
|
+
<p className="text-foreground text-sm">{p.justification}</p>
|
|
674
|
+
<ChangePreview changes={p.changes} />
|
|
675
|
+
</div>
|
|
676
|
+
</td>
|
|
677
|
+
</tr>
|
|
678
|
+
) : null}
|
|
679
|
+
</>
|
|
680
|
+
)
|
|
681
|
+
}
|