@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
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
4
|
+
import { AlertTriangle, Bot, Info, Loader2, Play, RefreshCw } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import {
|
|
7
|
+
SEO_AUTOPILOT_LIMITS,
|
|
8
|
+
fetchAutopilotSettings,
|
|
9
|
+
runAutopilotNow,
|
|
10
|
+
updateAutopilotSettings,
|
|
11
|
+
type SeoAutopilotHaltReason,
|
|
12
|
+
type SeoAutopilotIssueType,
|
|
13
|
+
type SeoAutopilotPauseReason,
|
|
14
|
+
type SeoAutopilotRunSummary,
|
|
15
|
+
type SeoAutopilotSettings,
|
|
16
|
+
type SeoAutopilotSettingsPatch,
|
|
17
|
+
} from '../../lib/seo-service.js'
|
|
18
|
+
import { brandAlignmentLabel } from '../../lib/brand-voice-client.js'
|
|
19
|
+
import { Skeleton } from '../../components/ui/Skeleton.js'
|
|
20
|
+
import { relativeAge } from '../seo/relative-age.js'
|
|
21
|
+
import { SettingsCard, SettingsToggleRow } from './components.js'
|
|
22
|
+
|
|
23
|
+
const INPUT_CLASS =
|
|
24
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
25
|
+
const CHECKBOX_CLASS =
|
|
26
|
+
'border-border accent-primary mt-1 h-4 w-4 shrink-0 rounded focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
27
|
+
const SECONDARY_BUTTON_CLASS =
|
|
28
|
+
'border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
29
|
+
const LINK_BUTTON_CLASS =
|
|
30
|
+
'text-primary rounded-sm underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The one mutation allowed in flight at a time. Inputs stay enabled while it
|
|
34
|
+
* runs (so focus and a blur-then-click sequence survive); only the action
|
|
35
|
+
* buttons lock and the card is marked `aria-busy`.
|
|
36
|
+
*/
|
|
37
|
+
type Mutation = 'save' | 'run'
|
|
38
|
+
|
|
39
|
+
interface TypeOption {
|
|
40
|
+
id: SeoAutopilotIssueType
|
|
41
|
+
label: string
|
|
42
|
+
help: string
|
|
43
|
+
producer: 'Rule-based' | 'AI'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The fix types offered, grouped for display. Must cover `SEO_AUTOPILOT_ISSUE_TYPES`
|
|
48
|
+
* exactly once each — the id type keeps stray ids out, and the render test
|
|
49
|
+
* pins the set against the mirrored constant so a type added server-side
|
|
50
|
+
* cannot silently go un-offered. Exported for that test only.
|
|
51
|
+
*/
|
|
52
|
+
export const AUTOPILOT_TYPE_GROUPS: ReadonlyArray<{
|
|
53
|
+
label: string
|
|
54
|
+
options: readonly TypeOption[]
|
|
55
|
+
}> = [
|
|
56
|
+
{
|
|
57
|
+
label: 'Titles',
|
|
58
|
+
options: [
|
|
59
|
+
{
|
|
60
|
+
id: 'missing-meta-title',
|
|
61
|
+
label: 'Missing meta title',
|
|
62
|
+
help: 'Writes a page title when none is set.',
|
|
63
|
+
producer: 'AI',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'meta-title-length',
|
|
67
|
+
label: 'Meta title length',
|
|
68
|
+
help: 'Trims or rewrites titles outside 30–60 characters.',
|
|
69
|
+
producer: 'Rule-based',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'duplicate-meta-title',
|
|
73
|
+
label: 'Duplicate meta title',
|
|
74
|
+
help: 'Rewrites a title shared with another page.',
|
|
75
|
+
producer: 'AI',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: 'Descriptions',
|
|
81
|
+
options: [
|
|
82
|
+
{
|
|
83
|
+
id: 'missing-meta-description',
|
|
84
|
+
label: 'Missing meta description',
|
|
85
|
+
help: 'Writes a description when none is set.',
|
|
86
|
+
producer: 'AI',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'meta-description-length',
|
|
90
|
+
label: 'Meta description length',
|
|
91
|
+
help: 'Trims or rewrites descriptions outside 120–160 characters.',
|
|
92
|
+
producer: 'Rule-based',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'duplicate-meta-description',
|
|
96
|
+
label: 'Duplicate meta description',
|
|
97
|
+
help: 'Rewrites a description shared with another page.',
|
|
98
|
+
producer: 'AI',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
label: 'Technical',
|
|
104
|
+
options: [
|
|
105
|
+
{
|
|
106
|
+
id: 'missing-canonical',
|
|
107
|
+
label: 'Missing canonical',
|
|
108
|
+
help: 'Sets the canonical URL to the page’s own URL.',
|
|
109
|
+
producer: 'Rule-based',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'accidental-noindex',
|
|
113
|
+
label: 'Accidental noindex',
|
|
114
|
+
help: 'Clears noindex on a published page.',
|
|
115
|
+
producer: 'Rule-based',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'missing-structured-data',
|
|
119
|
+
label: 'Missing structured data',
|
|
120
|
+
help: 'Adds the collection’s default Schema.org type.',
|
|
121
|
+
producer: 'Rule-based',
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
label: 'Redirects',
|
|
127
|
+
options: [
|
|
128
|
+
{
|
|
129
|
+
id: 'redirect',
|
|
130
|
+
label: '404-recovery redirects',
|
|
131
|
+
help: 'Creates a 301 when a dead URL matches a live page above the confidence threshold.',
|
|
132
|
+
producer: 'AI',
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
const TYPE_LABEL: Record<SeoAutopilotIssueType, string> = Object.fromEntries(
|
|
139
|
+
AUTOPILOT_TYPE_GROUPS.flatMap((g) => g.options.map((o) => [o.id, o.label])),
|
|
140
|
+
) as Record<SeoAutopilotIssueType, string>
|
|
141
|
+
|
|
142
|
+
const PAUSE_LABEL: Record<SeoAutopilotPauseReason, string> = {
|
|
143
|
+
outcome_regression: 'a page lost traffic after an autopilot change',
|
|
144
|
+
owner_unavailable: 'the admin who enabled it is no longer active',
|
|
145
|
+
manual: 'it was paused manually',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `budget_exhausted` is the run's wall-clock budget, not the daily change cap:
|
|
150
|
+
* the cap is a gate reason (`cap_per_day`) and never halts a run. Remaining
|
|
151
|
+
* proposals are simply picked up by the next tick.
|
|
152
|
+
*/
|
|
153
|
+
const HALT_LABEL: Record<SeoAutopilotHaltReason, string> = {
|
|
154
|
+
disabled: 'The run stopped because autopilot was disabled',
|
|
155
|
+
paused: 'The run stopped because autopilot was paused',
|
|
156
|
+
owner_unavailable: 'The run stopped because the admin who enabled autopilot is no longer active',
|
|
157
|
+
already_running: 'A run was already in progress',
|
|
158
|
+
verify_failures: 'The run stopped after too many verification failures',
|
|
159
|
+
budget_exhausted:
|
|
160
|
+
'The run stopped because it ran out of time; remaining proposals wait for the next tick',
|
|
161
|
+
ledger_write_failed: 'The run stopped because a change could not be recorded (it was undone)',
|
|
162
|
+
apply_failed:
|
|
163
|
+
'The run stopped because applying a change failed unexpectedly; details are in the server log',
|
|
164
|
+
run_failed: 'The run stopped with an unexpected error; details are in the server log',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isKnownHaltReason(reason: string): reason is SeoAutopilotHaltReason {
|
|
168
|
+
return Object.prototype.hasOwnProperty.call(HALT_LABEL, reason)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function haltLabel(reason: string): string {
|
|
172
|
+
return isKnownHaltReason(reason) ? HALT_LABEL[reason] : `The run stopped with an error: ${reason}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `relativeAge` returns `''` for future or unparsable timestamps; fall back to an absolute date. */
|
|
176
|
+
function lastRunLabel(iso: string | null): string {
|
|
177
|
+
if (!iso) return 'Never run'
|
|
178
|
+
const age = relativeAge(iso)
|
|
179
|
+
if (age) return `Last run ${age}`
|
|
180
|
+
const t = new Date(iso).getTime()
|
|
181
|
+
return Number.isFinite(t) ? `Last run ${new Date(t).toLocaleDateString()}` : 'Last run: unknown'
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function runToast(s: SeoAutopilotRunSummary): void {
|
|
185
|
+
const counts = `${s.applied} applied, ${s.verifyFailed} verify-failed${
|
|
186
|
+
s.reverted > 0 ? `, ${s.reverted} reverted` : ''
|
|
187
|
+
}.`
|
|
188
|
+
if (!s.halted || !s.haltedReason) {
|
|
189
|
+
toast.success(`Autopilot finished: ${counts}`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (s.haltedReason === 'already_running') {
|
|
193
|
+
toast.success('A run was already in progress — nothing new was applied.')
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
toast.warning(`${haltLabel(s.haltedReason)}. ${counts}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Settings → SEO "SEO Autopilot" card (spec §6). Owns its own fetch/save like
|
|
201
|
+
* `SeoDigestCard`; every control PUTs a sparse patch immediately and the card
|
|
202
|
+
* adopts the server's resolved policy from the response. One mutation (a save
|
|
203
|
+
* or a manual run) is allowed in flight at a time; saves queue behind each
|
|
204
|
+
* other so a blur-commit followed by a click both land, in order.
|
|
205
|
+
*/
|
|
206
|
+
export function SeoAutopilotCard({
|
|
207
|
+
canEdit,
|
|
208
|
+
onNavigate,
|
|
209
|
+
}: {
|
|
210
|
+
canEdit: boolean
|
|
211
|
+
onNavigate?: (path: string) => void
|
|
212
|
+
}) {
|
|
213
|
+
const baseId = useId()
|
|
214
|
+
const [settings, setSettings] = useState<SeoAutopilotSettings | null>(null)
|
|
215
|
+
/** Mirrors `settings` synchronously so a queued save reads the latest resolved policy. */
|
|
216
|
+
const settingsRef = useRef<SeoAutopilotSettings | null>(null)
|
|
217
|
+
const [loading, setLoading] = useState(true)
|
|
218
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
219
|
+
const [mutation, setMutation] = useState<Mutation | null>(null)
|
|
220
|
+
/** Mirrors `mutation` synchronously so a second call in the same tick cannot slip past. */
|
|
221
|
+
const mutationRef = useRef<Mutation | null>(null)
|
|
222
|
+
/** Saves are serialised through this chain instead of being dropped when one is in flight. */
|
|
223
|
+
const saveChainRef = useRef<Promise<unknown>>(Promise.resolve())
|
|
224
|
+
const mountedRef = useRef(true)
|
|
225
|
+
const busy = mutation !== null
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
mountedRef.current = true
|
|
229
|
+
return () => {
|
|
230
|
+
mountedRef.current = false
|
|
231
|
+
}
|
|
232
|
+
}, [])
|
|
233
|
+
|
|
234
|
+
const commitSettings = useCallback((next: SeoAutopilotSettings) => {
|
|
235
|
+
settingsRef.current = next
|
|
236
|
+
setSettings(next)
|
|
237
|
+
}, [])
|
|
238
|
+
|
|
239
|
+
const load = useCallback(async () => {
|
|
240
|
+
setLoading(true)
|
|
241
|
+
setLoadError(null)
|
|
242
|
+
const res = await fetchAutopilotSettings()
|
|
243
|
+
if (!mountedRef.current) return
|
|
244
|
+
if (res.settings) commitSettings(res.settings)
|
|
245
|
+
else setLoadError(res.error)
|
|
246
|
+
setLoading(false)
|
|
247
|
+
}, [commitSettings])
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
void load()
|
|
251
|
+
}, [load])
|
|
252
|
+
|
|
253
|
+
/** Re-read without the skeleton (after a run); a failure is a toast, not a card-level error. */
|
|
254
|
+
const refresh = useCallback(async () => {
|
|
255
|
+
const res = await fetchAutopilotSettings()
|
|
256
|
+
if (!mountedRef.current) return
|
|
257
|
+
if (res.settings) commitSettings(res.settings)
|
|
258
|
+
else toast.error(res.error)
|
|
259
|
+
}, [commitSettings])
|
|
260
|
+
|
|
261
|
+
const beginMutation = useCallback((kind: Mutation): boolean => {
|
|
262
|
+
if (mutationRef.current !== null) return false
|
|
263
|
+
mutationRef.current = kind
|
|
264
|
+
setMutation(kind)
|
|
265
|
+
return true
|
|
266
|
+
}, [])
|
|
267
|
+
const endMutation = useCallback(() => {
|
|
268
|
+
mutationRef.current = null
|
|
269
|
+
if (mountedRef.current) setMutation(null)
|
|
270
|
+
}, [])
|
|
271
|
+
/** An overlapping mutation is never dropped silently. */
|
|
272
|
+
const reportOverlap = useCallback(() => {
|
|
273
|
+
toast.info(
|
|
274
|
+
mutationRef.current === 'run'
|
|
275
|
+
? 'Autopilot is still running — try again when it finishes.'
|
|
276
|
+
: 'Still saving the previous change…',
|
|
277
|
+
)
|
|
278
|
+
}, [])
|
|
279
|
+
|
|
280
|
+
/** Resolves `true` when the server accepted the patch. */
|
|
281
|
+
async function performSave(
|
|
282
|
+
patch: SeoAutopilotSettingsPatch,
|
|
283
|
+
successMessage: string,
|
|
284
|
+
): Promise<boolean> {
|
|
285
|
+
const current = settingsRef.current
|
|
286
|
+
if (!current || !mountedRef.current) return false
|
|
287
|
+
if (!beginMutation('save')) {
|
|
288
|
+
reportOverlap()
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
// A pause for `owner_unavailable` is repaired by any save (the saver
|
|
293
|
+
// becomes the owner), so clear the stale reason in the same write —
|
|
294
|
+
// except when the save turns autopilot off: "disabled — resumed" would
|
|
295
|
+
// be contradictory and there is nothing to resume.
|
|
296
|
+
const resumes =
|
|
297
|
+
current.pausedAt !== null &&
|
|
298
|
+
current.pausedReason === 'owner_unavailable' &&
|
|
299
|
+
patch.enabled !== false
|
|
300
|
+
const body: SeoAutopilotSettingsPatch = resumes ? { ...patch, resume: true } : patch
|
|
301
|
+
const res = await updateAutopilotSettings(body)
|
|
302
|
+
if (!mountedRef.current) return false
|
|
303
|
+
if (!res.ok) {
|
|
304
|
+
toast.error(res.error ?? 'Failed to update autopilot settings')
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
toast.success(
|
|
308
|
+
resumes && !patch.resume ? `${successMessage} — autopilot resumed` : successMessage,
|
|
309
|
+
)
|
|
310
|
+
if (res.settings) commitSettings(res.settings)
|
|
311
|
+
else await refresh()
|
|
312
|
+
return true
|
|
313
|
+
} finally {
|
|
314
|
+
endMutation()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Queue a sparse patch behind any save already in flight. Callers await the
|
|
320
|
+
* result to know whether to keep or reset a local draft.
|
|
321
|
+
*/
|
|
322
|
+
function save(patch: SeoAutopilotSettingsPatch, successMessage: string): Promise<boolean> {
|
|
323
|
+
const next = saveChainRef.current.then(() => performSave(patch, successMessage))
|
|
324
|
+
saveChainRef.current = next.catch(() => undefined)
|
|
325
|
+
return next
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function handleRun() {
|
|
329
|
+
if (!beginMutation('run')) {
|
|
330
|
+
reportOverlap()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const res = await runAutopilotNow()
|
|
335
|
+
if (!mountedRef.current) return
|
|
336
|
+
if (!res.ok || !res.summary) {
|
|
337
|
+
toast.error(res.error ?? 'Autopilot run failed')
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
runToast(res.summary)
|
|
341
|
+
await refresh()
|
|
342
|
+
} finally {
|
|
343
|
+
endMutation()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (loading) {
|
|
348
|
+
return (
|
|
349
|
+
<SettingsCard title="SEO Autopilot">
|
|
350
|
+
<div aria-busy="true" aria-live="polite">
|
|
351
|
+
<span className="sr-only">Loading autopilot settings…</span>
|
|
352
|
+
<Skeleton lines={4} />
|
|
353
|
+
</div>
|
|
354
|
+
</SettingsCard>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
if (!settings) {
|
|
358
|
+
return (
|
|
359
|
+
<SettingsCard title="SEO Autopilot">
|
|
360
|
+
<div
|
|
361
|
+
role="alert"
|
|
362
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
|
|
363
|
+
>
|
|
364
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
365
|
+
<span className="flex-1">
|
|
366
|
+
Failed to load autopilot settings{loadError ? `: ${loadError}` : ''}.
|
|
367
|
+
</span>
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={() => void load()}
|
|
371
|
+
className="border-destructive/40 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-1 transition-colors hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
|
|
372
|
+
>
|
|
373
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
374
|
+
Retry
|
|
375
|
+
</button>
|
|
376
|
+
</div>
|
|
377
|
+
</SettingsCard>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const paused = settings.pausedAt !== null
|
|
382
|
+
const pausedTypes = Object.entries(settings.pausedTypes) as Array<[SeoAutopilotIssueType, string]>
|
|
383
|
+
// Inputs lock only for read-only users; a save in flight must not steal
|
|
384
|
+
// focus or swallow the next click (saves queue). Action buttons also lock while busy.
|
|
385
|
+
const disabled = !canEdit
|
|
386
|
+
const actionDisabled = !canEdit || busy
|
|
387
|
+
const running = mutation === 'run'
|
|
388
|
+
const lastRun = settings.lastRun
|
|
389
|
+
const haltNeutral = lastRun?.haltedReason === 'already_running'
|
|
390
|
+
|
|
391
|
+
// Governance veto (spec §2): an enabled type whose AI feature has "Requires
|
|
392
|
+
// approval" on can never pass the gate. Warn only when it actually bites.
|
|
393
|
+
const anyMetaEnabled = AUTOPILOT_TYPE_GROUPS.some((g) =>
|
|
394
|
+
g.options.some((o) => o.id !== 'redirect' && settings.types[o.id] === true),
|
|
395
|
+
)
|
|
396
|
+
const vetoed: string[] = []
|
|
397
|
+
if (anyMetaEnabled && settings.governance.issueFixApprovalRequired) vetoed.push('Issue fixes')
|
|
398
|
+
if (settings.types.redirect === true && settings.governance.redirectApprovalRequired) {
|
|
399
|
+
vetoed.push('404-recovery redirects')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Why "Run now" is unavailable — surfaced as `title` for pointer users and
|
|
403
|
+
// as an sr-only description for keyboard/AT users. `null` when runnable.
|
|
404
|
+
const runHint = !settings.enabled
|
|
405
|
+
? 'Enable autopilot to run it now'
|
|
406
|
+
: paused
|
|
407
|
+
? 'Resume autopilot to run it now'
|
|
408
|
+
: null
|
|
409
|
+
const runHintId = `${baseId}-run-hint`
|
|
410
|
+
|
|
411
|
+
const settingsAiLink = (
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
className={LINK_BUTTON_CLASS}
|
|
415
|
+
onClick={() => onNavigate?.('/settings?tab=ai')}
|
|
416
|
+
>
|
|
417
|
+
Settings → AI
|
|
418
|
+
</button>
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<SettingsCard
|
|
423
|
+
title="SEO Autopilot"
|
|
424
|
+
description="Apply SEO fixes without a click. Each fix type is opt-in; every unattended change is gated, recorded, and revertible from SEO → Proposals."
|
|
425
|
+
busy={busy}
|
|
426
|
+
actions={
|
|
427
|
+
<>
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
className="bg-primary text-primary-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-3 py-2 text-base font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
|
431
|
+
onClick={() => void handleRun()}
|
|
432
|
+
disabled={actionDisabled || runHint !== null}
|
|
433
|
+
aria-busy={running || undefined}
|
|
434
|
+
title={runHint ?? undefined}
|
|
435
|
+
aria-describedby={runHint ? runHintId : undefined}
|
|
436
|
+
>
|
|
437
|
+
{running ? (
|
|
438
|
+
<Loader2 size={16} className="motion-safe:animate-spin" aria-hidden="true" />
|
|
439
|
+
) : (
|
|
440
|
+
<Play size={16} aria-hidden="true" />
|
|
441
|
+
)}
|
|
442
|
+
Run now
|
|
443
|
+
</button>
|
|
444
|
+
{runHint ? (
|
|
445
|
+
<span id={runHintId} className="sr-only">
|
|
446
|
+
{runHint}
|
|
447
|
+
</span>
|
|
448
|
+
) : null}
|
|
449
|
+
</>
|
|
450
|
+
}
|
|
451
|
+
>
|
|
452
|
+
<div className="flex flex-col gap-5">
|
|
453
|
+
<SettingsToggleRow
|
|
454
|
+
label="Enable autopilot"
|
|
455
|
+
description="Runs daily after the search-data sync. The admin who enables it is the author of record for every change."
|
|
456
|
+
checked={settings.enabled}
|
|
457
|
+
onChange={(v) =>
|
|
458
|
+
void save({ enabled: v }, v ? 'Autopilot enabled' : 'Autopilot disabled')
|
|
459
|
+
}
|
|
460
|
+
disabled={disabled}
|
|
461
|
+
/>
|
|
462
|
+
|
|
463
|
+
<div className="flex flex-col gap-1">
|
|
464
|
+
<p
|
|
465
|
+
className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm"
|
|
466
|
+
role="status"
|
|
467
|
+
aria-label="Last run"
|
|
468
|
+
>
|
|
469
|
+
<Bot size={16} className="shrink-0" aria-hidden="true" />
|
|
470
|
+
<span>
|
|
471
|
+
{settings.lastRunAt ? (
|
|
472
|
+
<time
|
|
473
|
+
dateTime={settings.lastRunAt}
|
|
474
|
+
title={new Date(settings.lastRunAt).toLocaleString()}
|
|
475
|
+
>
|
|
476
|
+
{lastRunLabel(settings.lastRunAt)}
|
|
477
|
+
</time>
|
|
478
|
+
) : (
|
|
479
|
+
lastRunLabel(null)
|
|
480
|
+
)}
|
|
481
|
+
</span>
|
|
482
|
+
{lastRun ? (
|
|
483
|
+
<>
|
|
484
|
+
<span aria-hidden="true">·</span>
|
|
485
|
+
<span>{lastRun.applied} applied</span>
|
|
486
|
+
<span aria-hidden="true">·</span>
|
|
487
|
+
<span>{lastRun.verifyFailed} verify-failed</span>
|
|
488
|
+
<span aria-hidden="true">·</span>
|
|
489
|
+
<span>{lastRun.reverted} reverted</span>
|
|
490
|
+
</>
|
|
491
|
+
) : null}
|
|
492
|
+
</p>
|
|
493
|
+
{lastRun?.halted && lastRun.haltedReason ? (
|
|
494
|
+
<p
|
|
495
|
+
className={`flex items-start gap-2 text-sm ${
|
|
496
|
+
haltNeutral ? 'text-muted-foreground' : 'text-warning'
|
|
497
|
+
}`}
|
|
498
|
+
role="status"
|
|
499
|
+
aria-label="Run stopped"
|
|
500
|
+
>
|
|
501
|
+
{haltNeutral ? (
|
|
502
|
+
<Info size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
503
|
+
) : (
|
|
504
|
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
505
|
+
)}
|
|
506
|
+
<span>{haltLabel(lastRun.haltedReason)}.</span>
|
|
507
|
+
</p>
|
|
508
|
+
) : null}
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
{paused ? (
|
|
512
|
+
<section
|
|
513
|
+
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-3 rounded-lg border px-4 py-3"
|
|
514
|
+
aria-label="Autopilot is paused"
|
|
515
|
+
>
|
|
516
|
+
<p className="text-foreground flex items-start gap-2 text-sm">
|
|
517
|
+
<AlertTriangle
|
|
518
|
+
size={16}
|
|
519
|
+
className="text-warning mt-0.5 shrink-0"
|
|
520
|
+
aria-hidden="true"
|
|
521
|
+
/>
|
|
522
|
+
<span>
|
|
523
|
+
Autopilot is <span className="font-medium">paused</span>
|
|
524
|
+
{settings.pausedReason ? ` because ${PAUSE_LABEL[settings.pausedReason]}` : ''}.
|
|
525
|
+
{settings.pausedReason === 'owner_unavailable'
|
|
526
|
+
? ' Saving any setting resumes autopilot with you as the author of record.'
|
|
527
|
+
: ''}
|
|
528
|
+
</span>
|
|
529
|
+
</p>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className={SECONDARY_BUTTON_CLASS}
|
|
533
|
+
onClick={() => void save({ resume: true }, 'Autopilot resumed')}
|
|
534
|
+
disabled={actionDisabled}
|
|
535
|
+
>
|
|
536
|
+
Resume
|
|
537
|
+
</button>
|
|
538
|
+
</section>
|
|
539
|
+
) : null}
|
|
540
|
+
|
|
541
|
+
{pausedTypes.length > 0 ? (
|
|
542
|
+
<ul className="flex flex-col gap-2" aria-label="Paused fix types">
|
|
543
|
+
{pausedTypes.map(([type, reason]) => (
|
|
544
|
+
<li
|
|
545
|
+
key={type}
|
|
546
|
+
className="border-border bg-muted flex flex-wrap items-center justify-between gap-3 rounded-lg border px-4 py-2 text-sm"
|
|
547
|
+
>
|
|
548
|
+
{/* The server writes a complete sentence per paused type; render it verbatim. */}
|
|
549
|
+
<span className="text-foreground">
|
|
550
|
+
<span className="font-medium">{TYPE_LABEL[type] ?? type}</span> — {reason}
|
|
551
|
+
</span>
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
className={SECONDARY_BUTTON_CLASS}
|
|
555
|
+
onClick={() =>
|
|
556
|
+
void save({ resumeTypes: [type] }, `Resumed ${TYPE_LABEL[type] ?? type}`)
|
|
557
|
+
}
|
|
558
|
+
disabled={actionDisabled}
|
|
559
|
+
>
|
|
560
|
+
Resume {TYPE_LABEL[type] ?? type}
|
|
561
|
+
</button>
|
|
562
|
+
</li>
|
|
563
|
+
))}
|
|
564
|
+
</ul>
|
|
565
|
+
) : null}
|
|
566
|
+
|
|
567
|
+
<fieldset className="flex flex-col gap-4" disabled={disabled}>
|
|
568
|
+
<legend className="text-foreground mb-2 text-base font-medium">Fix types</legend>
|
|
569
|
+
{AUTOPILOT_TYPE_GROUPS.map((group) => (
|
|
570
|
+
<div key={group.label}>
|
|
571
|
+
<p className="text-muted-foreground mb-2 text-sm font-medium">{group.label}</p>
|
|
572
|
+
<ul className="flex flex-col gap-2">
|
|
573
|
+
{group.options.map((opt) => {
|
|
574
|
+
const inputId = `${baseId}-${opt.id}`
|
|
575
|
+
const helpId = `${inputId}-help`
|
|
576
|
+
const typePaused = settings.pausedTypes[opt.id] !== undefined
|
|
577
|
+
return (
|
|
578
|
+
<li key={opt.id} className="flex items-start gap-3">
|
|
579
|
+
<input
|
|
580
|
+
id={inputId}
|
|
581
|
+
type="checkbox"
|
|
582
|
+
className={CHECKBOX_CLASS}
|
|
583
|
+
checked={settings.types[opt.id] === true}
|
|
584
|
+
onChange={(e) =>
|
|
585
|
+
void save(
|
|
586
|
+
{ types: { [opt.id]: e.target.checked } },
|
|
587
|
+
`${opt.label} ${e.target.checked ? 'enabled' : 'disabled'}`,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
disabled={disabled}
|
|
591
|
+
aria-describedby={helpId}
|
|
592
|
+
/>
|
|
593
|
+
<div className="min-w-0">
|
|
594
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
595
|
+
<label htmlFor={inputId} className="text-foreground text-sm font-medium">
|
|
596
|
+
{opt.label}
|
|
597
|
+
</label>
|
|
598
|
+
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
|
599
|
+
{opt.producer}
|
|
600
|
+
</span>
|
|
601
|
+
{typePaused ? (
|
|
602
|
+
<span className="text-warning inline-flex items-center gap-1 text-xs font-medium">
|
|
603
|
+
<AlertTriangle size={12} aria-hidden="true" />
|
|
604
|
+
Paused
|
|
605
|
+
</span>
|
|
606
|
+
) : null}
|
|
607
|
+
</div>
|
|
608
|
+
<p id={helpId} className="text-muted-foreground text-sm">
|
|
609
|
+
{opt.help}
|
|
610
|
+
</p>
|
|
611
|
+
</div>
|
|
612
|
+
</li>
|
|
613
|
+
)
|
|
614
|
+
})}
|
|
615
|
+
</ul>
|
|
616
|
+
</div>
|
|
617
|
+
))}
|
|
618
|
+
</fieldset>
|
|
619
|
+
|
|
620
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
621
|
+
<NumberField
|
|
622
|
+
id={`${baseId}-brand`}
|
|
623
|
+
label="Minimum brand alignment"
|
|
624
|
+
hint={`${settings.minBrandAlignment} = ${brandAlignmentLabel(settings.minBrandAlignment)}. 0–100.`}
|
|
625
|
+
value={settings.minBrandAlignment}
|
|
626
|
+
min={SEO_AUTOPILOT_LIMITS.minBrandAlignment.min}
|
|
627
|
+
max={SEO_AUTOPILOT_LIMITS.minBrandAlignment.max}
|
|
628
|
+
disabled={disabled}
|
|
629
|
+
onCommit={(v) => save({ minBrandAlignment: v }, 'Brand threshold saved')}
|
|
630
|
+
/>
|
|
631
|
+
<NumberField
|
|
632
|
+
id={`${baseId}-redirect`}
|
|
633
|
+
label="Minimum redirect confidence"
|
|
634
|
+
hint="50–100."
|
|
635
|
+
value={settings.redirectMinConfidence}
|
|
636
|
+
min={SEO_AUTOPILOT_LIMITS.redirectMinConfidence.min}
|
|
637
|
+
max={SEO_AUTOPILOT_LIMITS.redirectMinConfidence.max}
|
|
638
|
+
disabled={disabled}
|
|
639
|
+
onCommit={(v) => save({ redirectMinConfidence: v }, 'Redirect threshold saved')}
|
|
640
|
+
/>
|
|
641
|
+
<NumberField
|
|
642
|
+
id={`${baseId}-per-run`}
|
|
643
|
+
label="Max changes per run"
|
|
644
|
+
hint="1–25."
|
|
645
|
+
value={settings.maxPerRun}
|
|
646
|
+
min={SEO_AUTOPILOT_LIMITS.maxPerRun.min}
|
|
647
|
+
max={SEO_AUTOPILOT_LIMITS.maxPerRun.max}
|
|
648
|
+
disabled={disabled}
|
|
649
|
+
onCommit={(v) => save({ maxPerRun: v }, 'Per-run cap saved')}
|
|
650
|
+
/>
|
|
651
|
+
<NumberField
|
|
652
|
+
id={`${baseId}-per-day`}
|
|
653
|
+
label="Max changes per day"
|
|
654
|
+
hint="1–100."
|
|
655
|
+
value={settings.maxPerDay}
|
|
656
|
+
min={SEO_AUTOPILOT_LIMITS.maxPerDay.min}
|
|
657
|
+
max={SEO_AUTOPILOT_LIMITS.maxPerDay.max}
|
|
658
|
+
disabled={disabled}
|
|
659
|
+
onCommit={(v) => save({ maxPerDay: v }, 'Per-day cap saved')}
|
|
660
|
+
/>
|
|
661
|
+
<NumberField
|
|
662
|
+
id={`${baseId}-verify`}
|
|
663
|
+
label="Stop a run after N verification failures"
|
|
664
|
+
hint="1–10."
|
|
665
|
+
value={settings.verifyFailuresPerRun}
|
|
666
|
+
min={SEO_AUTOPILOT_LIMITS.verifyFailuresPerRun.min}
|
|
667
|
+
max={SEO_AUTOPILOT_LIMITS.verifyFailuresPerRun.max}
|
|
668
|
+
disabled={disabled}
|
|
669
|
+
onCommit={(v) => save({ verifyFailuresPerRun: v }, 'Breaker saved')}
|
|
670
|
+
/>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
<div className="border-border flex flex-col gap-4 border-t pt-4">
|
|
674
|
+
<SettingsToggleRow
|
|
675
|
+
label="Outcome watch"
|
|
676
|
+
description="Compare Search Console clicks before and after each change; revert and pause the type on a regression."
|
|
677
|
+
checked={settings.outcomeWatch.enabled}
|
|
678
|
+
onChange={(v) =>
|
|
679
|
+
void save(
|
|
680
|
+
{ outcomeWatch: { enabled: v } },
|
|
681
|
+
v ? 'Outcome watch enabled' : 'Outcome watch disabled',
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
disabled={disabled}
|
|
685
|
+
/>
|
|
686
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
687
|
+
<NumberField
|
|
688
|
+
id={`${baseId}-days`}
|
|
689
|
+
label="Window (days)"
|
|
690
|
+
hint="7–28."
|
|
691
|
+
value={settings.outcomeWatch.days}
|
|
692
|
+
min={SEO_AUTOPILOT_LIMITS.outcomeDays.min}
|
|
693
|
+
max={SEO_AUTOPILOT_LIMITS.outcomeDays.max}
|
|
694
|
+
disabled={disabled || !settings.outcomeWatch.enabled}
|
|
695
|
+
onCommit={(v) => save({ outcomeWatch: { days: v } }, 'Window saved')}
|
|
696
|
+
/>
|
|
697
|
+
<NumberField
|
|
698
|
+
id={`${baseId}-drop`}
|
|
699
|
+
label="Regression threshold (%)"
|
|
700
|
+
hint="20–80."
|
|
701
|
+
value={Math.round(settings.outcomeWatch.dropRatio * 100)}
|
|
702
|
+
min={SEO_AUTOPILOT_LIMITS.dropPercent.min}
|
|
703
|
+
max={SEO_AUTOPILOT_LIMITS.dropPercent.max}
|
|
704
|
+
disabled={disabled || !settings.outcomeWatch.enabled}
|
|
705
|
+
onCommit={(v) => save({ outcomeWatch: { dropRatio: v / 100 } }, 'Threshold saved')}
|
|
706
|
+
/>
|
|
707
|
+
<NumberField
|
|
708
|
+
id={`${baseId}-prior`}
|
|
709
|
+
label="Minimum prior clicks"
|
|
710
|
+
hint="Pages below this are not judged."
|
|
711
|
+
value={settings.outcomeWatch.minPriorClicks}
|
|
712
|
+
min={SEO_AUTOPILOT_LIMITS.minPriorClicks.min}
|
|
713
|
+
max={SEO_AUTOPILOT_LIMITS.minPriorClicks.max}
|
|
714
|
+
disabled={disabled || !settings.outcomeWatch.enabled}
|
|
715
|
+
onCommit={(v) => save({ outcomeWatch: { minPriorClicks: v } }, 'Floor saved')}
|
|
716
|
+
/>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
{vetoed.length > 0 ? (
|
|
721
|
+
<section
|
|
722
|
+
className="border-warning/30 bg-warning/10 flex items-start gap-2 rounded-lg border px-4 py-3 text-sm"
|
|
723
|
+
aria-label="Governance warning"
|
|
724
|
+
>
|
|
725
|
+
<AlertTriangle size={16} className="text-warning mt-0.5 shrink-0" aria-hidden="true" />
|
|
726
|
+
<p className="text-foreground">
|
|
727
|
+
{vetoed.join(' and ')} {vetoed.length > 1 ? 'are' : 'is'} enabled here but the AI
|
|
728
|
+
feature still requires approval, so autopilot will skip{' '}
|
|
729
|
+
{vetoed.length > 1 ? 'them' : 'it'}. Turn off “Requires approval” in {settingsAiLink}{' '}
|
|
730
|
+
to let it run.
|
|
731
|
+
</p>
|
|
732
|
+
</section>
|
|
733
|
+
) : (
|
|
734
|
+
<p className="text-muted-foreground text-sm">
|
|
735
|
+
AI governance still applies: a feature with “Requires approval” on in {settingsAiLink}{' '}
|
|
736
|
+
is never auto-applied, whatever is enabled here.
|
|
737
|
+
</p>
|
|
738
|
+
)}
|
|
739
|
+
</div>
|
|
740
|
+
</SettingsCard>
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* A bounded integer input that commits on blur (or Enter). The draft is local
|
|
746
|
+
* so typing never fires a PUT per keystroke; an unchanged or unparsable blur
|
|
747
|
+
* restores the saved value without a request. Out-of-range values are clamped
|
|
748
|
+
* to the server's limits before they are sent. A rejected commit (`onCommit`
|
|
749
|
+
* resolves `false`) falls back to the last saved value so the input never
|
|
750
|
+
* shows text the server refused.
|
|
751
|
+
*/
|
|
752
|
+
function NumberField({
|
|
753
|
+
id,
|
|
754
|
+
label,
|
|
755
|
+
hint,
|
|
756
|
+
value,
|
|
757
|
+
min,
|
|
758
|
+
max,
|
|
759
|
+
disabled,
|
|
760
|
+
onCommit,
|
|
761
|
+
}: {
|
|
762
|
+
id: string
|
|
763
|
+
label: string
|
|
764
|
+
hint: string
|
|
765
|
+
value: number
|
|
766
|
+
min: number
|
|
767
|
+
max: number
|
|
768
|
+
disabled: boolean
|
|
769
|
+
onCommit: (v: number) => Promise<boolean>
|
|
770
|
+
}) {
|
|
771
|
+
const [draft, setDraft] = useState(String(value))
|
|
772
|
+
const [syncedValue, setSyncedValue] = useState(value)
|
|
773
|
+
// Adopt a new server value without an effect: reset the draft during render
|
|
774
|
+
// when the prop changes (React's recommended "derive state from props" form).
|
|
775
|
+
if (value !== syncedValue) {
|
|
776
|
+
setSyncedValue(value)
|
|
777
|
+
setDraft(String(value))
|
|
778
|
+
}
|
|
779
|
+
const hintId = `${id}-hint`
|
|
780
|
+
|
|
781
|
+
function commit() {
|
|
782
|
+
const n = Number(draft)
|
|
783
|
+
if (draft.trim() === '' || !Number.isFinite(n)) {
|
|
784
|
+
setDraft(String(value))
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
const next = Math.max(min, Math.min(max, Math.round(n)))
|
|
788
|
+
if (next === value) {
|
|
789
|
+
setDraft(String(value))
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
void onCommit(next).then((ok) => {
|
|
793
|
+
// On success the parent adopts the server value and the derive-from-props
|
|
794
|
+
// branch above resets the draft; on failure `value` is unchanged, so reset here.
|
|
795
|
+
if (!ok) setDraft(String(value))
|
|
796
|
+
})
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div>
|
|
801
|
+
<label htmlFor={id} className="text-foreground mb-1 block text-sm font-medium">
|
|
802
|
+
{label}
|
|
803
|
+
</label>
|
|
804
|
+
<input
|
|
805
|
+
id={id}
|
|
806
|
+
type="number"
|
|
807
|
+
inputMode="numeric"
|
|
808
|
+
className={INPUT_CLASS}
|
|
809
|
+
min={min}
|
|
810
|
+
max={max}
|
|
811
|
+
step={1}
|
|
812
|
+
value={draft}
|
|
813
|
+
disabled={disabled}
|
|
814
|
+
aria-describedby={hintId}
|
|
815
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
816
|
+
onBlur={commit}
|
|
817
|
+
onKeyDown={(e) => {
|
|
818
|
+
if (e.key === 'Enter') e.currentTarget.blur()
|
|
819
|
+
}}
|
|
820
|
+
/>
|
|
821
|
+
<p id={hintId} className="text-muted-foreground mt-1 text-sm">
|
|
822
|
+
{hint}
|
|
823
|
+
</p>
|
|
824
|
+
</div>
|
|
825
|
+
)
|
|
826
|
+
}
|