@prenta/admin 2.7.0 → 2.8.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 +34 -0
- package/design-system/tokens.css +17 -0
- package/design-system/tokens.json +35 -1
- package/design-system/tokens.scss +15 -0
- package/design-system/tokens.tailwind.cjs +14 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +2 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
- package/dist/__tests__/lib/job-view.test.js +100 -3
- package/dist/__tests__/lib/job-view.test.js.map +1 -1
- package/dist/__tests__/lib/jobs-service.test.js +56 -2
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-list.render.test.js +7 -5
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/lib/api.d.ts +2 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-view.d.ts +43 -0
- package/dist/lib/job-view.d.ts.map +1 -1
- package/dist/lib/job-view.js +57 -1
- package/dist/lib/job-view.js.map +1 -1
- package/dist/lib/jobs-service.d.ts +19 -1
- package/dist/lib/jobs-service.d.ts.map +1 -1
- package/dist/lib/jobs-service.js +41 -0
- package/dist/lib/jobs-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/AppearanceChrome.d.ts +22 -4
- package/dist/views/AppearanceChrome.d.ts.map +1 -1
- package/dist/views/AppearanceChrome.js +451 -15
- package/dist/views/AppearanceChrome.js.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.js +13 -3
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
- package/dist/views/Jobs/DrawerReview.d.ts +7 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
- package/dist/views/Jobs/DrawerReview.js +1 -1
- package/dist/views/Jobs/DrawerReview.js.map +1 -1
- package/dist/views/Jobs/JobDrawer.d.ts +23 -3
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
- package/dist/views/Jobs/JobDrawer.js +30 -5
- package/dist/views/Jobs/JobDrawer.js.map +1 -1
- package/dist/views/Jobs/JobsTable.d.ts +4 -2
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
- package/dist/views/Jobs/JobsTable.js +6 -5
- package/dist/views/Jobs/JobsTable.js.map +1 -1
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
- package/dist/views/Jobs/WorkerStrip.js +2 -2
- package/dist/views/Jobs/WorkerStrip.js.map +1 -1
- package/dist/views/Jobs/focus.d.ts +6 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -1
- package/dist/views/Jobs/focus.js +10 -0
- package/dist/views/Jobs/focus.js.map +1 -1
- package/dist/views/Jobs.d.ts +12 -3
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +109 -31
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +16 -2
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +2 -0
- package/src/__tests__/lib/job-view.test.ts +123 -2
- package/src/__tests__/lib/jobs-service.test.ts +72 -3
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
- package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/lib/api.ts +12 -1
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-view.ts +95 -1
- package/src/lib/jobs-service.ts +56 -1
- package/src/styles/prenta-tokens.css +17 -0
- package/src/styles/theme.css +15 -0
- package/src/views/AppearanceChrome.tsx +704 -46
- package/src/views/Jobs/CancelJobDialog.tsx +20 -2
- package/src/views/Jobs/DrawerReview.tsx +1 -1
- package/src/views/Jobs/JobDrawer.tsx +83 -3
- package/src/views/Jobs/JobsTable.tsx +11 -4
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +16 -11
- package/src/views/Jobs/focus.ts +11 -0
- package/src/views/Jobs.tsx +163 -49
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/dist/views/Jobs/placeholders.d.ts +0 -6
- package/dist/views/Jobs/placeholders.d.ts.map +0 -1
- package/dist/views/Jobs/placeholders.js +0 -20
- package/dist/views/Jobs/placeholders.js.map +0 -1
- package/src/views/Jobs/placeholders.tsx +0 -66
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
5
|
+
import type { BatchAiEstimate, BatchAiPreviewItem, BatchAiTextField, Job } from '@prenta/core'
|
|
6
|
+
import { Button } from '../../components/ui/Button.js'
|
|
7
|
+
import {
|
|
8
|
+
CUSTOM_FIELDS,
|
|
9
|
+
INSTRUCTION_MAX,
|
|
10
|
+
MEDIA_SOURCE,
|
|
11
|
+
batchOp,
|
|
12
|
+
batchRequest,
|
|
13
|
+
batchSources,
|
|
14
|
+
estimateKey,
|
|
15
|
+
estimateNote,
|
|
16
|
+
estimateTiles,
|
|
17
|
+
instructionError,
|
|
18
|
+
isAdminRole,
|
|
19
|
+
matchProblem,
|
|
20
|
+
operationFits,
|
|
21
|
+
previewKey,
|
|
22
|
+
runsOnMedia,
|
|
23
|
+
type BatchDraft,
|
|
24
|
+
type BatchOperation,
|
|
25
|
+
type BatchSelection,
|
|
26
|
+
} from '../../lib/batch-wizard.js'
|
|
27
|
+
import { JOB_OPS } from '../../lib/job-view.js'
|
|
28
|
+
import { createBatchAiJob, estimateBatchAi, previewBatchAi } from '../../lib/jobs-service.js'
|
|
29
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
30
|
+
import { BusyButton } from './BusyButton.js'
|
|
31
|
+
import { LinkLine } from './DrawerReview.js'
|
|
32
|
+
import { useReturnFocus } from './focus.js'
|
|
33
|
+
import { JobIcons } from './icons.js'
|
|
34
|
+
|
|
35
|
+
const STEPS = ['What to change', 'Which documents', 'Review & queue'] as const
|
|
36
|
+
const AUTO_ADMIN_ONLY = 'Only an admin can apply changes automatically.'
|
|
37
|
+
/** The server's reason, cms-core `parseBatchAiRequest`. */
|
|
38
|
+
const AUTO_KEEPS_STATUS =
|
|
39
|
+
'Applying automatically keeps each document’s status. To publish, review the changes and approve them with Publish.'
|
|
40
|
+
|
|
41
|
+
/* jobs-new.jsx `.nb-*`, as utilities. */
|
|
42
|
+
const LABEL_TEXT = 'text-sm2 text-txt font-semibold leading-[normal]'
|
|
43
|
+
const LABEL = `${LABEL_TEXT} mb-[5px] block p-0`
|
|
44
|
+
const HELP = 'text-hint text-faint'
|
|
45
|
+
/*
|
|
46
|
+
* `.nb-in`. Its focus ring (2px --acc-m, no offset, an --acc border) replaces
|
|
47
|
+
* the admin-wide one, which is unlayered, hence the `!`.
|
|
48
|
+
*/
|
|
49
|
+
const FIELD =
|
|
50
|
+
'rounded-8 border-bdr bg-card text-txt text-body focus:border-acc w-full border px-2.5 focus:outline-2! focus:outline-offset-0! focus:outline-[var(--acc-m)]! disabled:cursor-not-allowed disabled:opacity-60'
|
|
51
|
+
const CARD =
|
|
52
|
+
'rounded-10 border-bdr text-body text-txt font-normal leading-[normal] has-checked:border-acc has-checked:bg-acc-l has-focus-visible:outline-acc flex cursor-pointer items-start gap-2.5 border px-3 py-2.5 has-disabled:cursor-not-allowed has-disabled:opacity-55 has-focus-visible:outline-2 has-focus-visible:outline-offset-2'
|
|
53
|
+
const RADIO = 'accent-acc mt-[3px] shrink-0'
|
|
54
|
+
const SMALL = 'text-meta text-sub mt-px block'
|
|
55
|
+
|
|
56
|
+
interface EstimateState {
|
|
57
|
+
key: string
|
|
58
|
+
value: BatchAiEstimate | null
|
|
59
|
+
loading: boolean
|
|
60
|
+
error: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface PreviewState {
|
|
64
|
+
key: string
|
|
65
|
+
items: BatchAiPreviewItem[] | null
|
|
66
|
+
loading: boolean
|
|
67
|
+
error: string | null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface NewBatchJobProps {
|
|
71
|
+
/** The signed-in user's role. Only OWNER and ADMIN may apply changes automatically. */
|
|
72
|
+
role?: string | null
|
|
73
|
+
/** The CMS config; its page and post collections fill the Content select. */
|
|
74
|
+
config?: unknown
|
|
75
|
+
/**
|
|
76
|
+
* Documents picked in a list (P-21's "Run with AI…"). Step 2 then shows
|
|
77
|
+
* "{n} selected", Content is locked to their source, and they run without
|
|
78
|
+
* the operation's "Only where" preset.
|
|
79
|
+
*/
|
|
80
|
+
selection?: BatchSelection | null
|
|
81
|
+
/** The operation to start on. Defaults to alt text for picked images, else meta descriptions. */
|
|
82
|
+
operation?: BatchOperation
|
|
83
|
+
onClose: () => void
|
|
84
|
+
/** The job the server queued. The wizard closes after this. */
|
|
85
|
+
onQueued: (job: Job) => void
|
|
86
|
+
/**
|
|
87
|
+
* Where focus goes on close when the control that opened the wizard is
|
|
88
|
+
* gone, or after a job was queued (the Jobs list focuses its new row).
|
|
89
|
+
*/
|
|
90
|
+
onFocusFallback?: (job: Job | null) => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The step bar: 3px segments, the current one in the accent, finished ones filled. */
|
|
94
|
+
function StepBar({ step }: { step: number }) {
|
|
95
|
+
return (
|
|
96
|
+
<ol aria-label="Steps" className="m-0 flex list-none gap-1.5 px-5 pt-3">
|
|
97
|
+
{STEPS.map((label, i) => (
|
|
98
|
+
<li
|
|
99
|
+
key={label}
|
|
100
|
+
aria-current={i === step ? 'step' : undefined}
|
|
101
|
+
className={`text-hint flex min-w-0 flex-1 flex-col gap-[5px] font-semibold ${
|
|
102
|
+
i === step ? 'text-acc' : i < step ? 'text-sub' : 'text-faint'
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
<i
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
className={`h-[3px] rounded-[2px] ${i <= step ? 'bg-acc' : 'bg-bdr'}`}
|
|
108
|
+
/>
|
|
109
|
+
<span className="truncate">{label}</span>
|
|
110
|
+
</li>
|
|
111
|
+
))}
|
|
112
|
+
</ol>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function Spinner() {
|
|
117
|
+
return (
|
|
118
|
+
<i
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
className="inline-block size-3 shrink-0 rounded-full border-2 border-current border-r-transparent motion-safe:animate-spin"
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The read-only "Only where" box: `.nb-in` as text. */
|
|
126
|
+
function OnlyWhere({ children }: { children: ReactNode }) {
|
|
127
|
+
return (
|
|
128
|
+
<div>
|
|
129
|
+
<span className={LABEL}>Only where</span>
|
|
130
|
+
<div className="rounded-8 border-bdr bg-card text-body text-sub flex min-h-9 items-center gap-2 border px-2.5 py-1.5">
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The New batch AI job wizard (jobs-new.jsx, TRACKING P-20): what to change,
|
|
139
|
+
* which documents, then review and queue. A centred modal, 680px wide; a
|
|
140
|
+
* full-screen sheet on phones. The match count and sample come from
|
|
141
|
+
* `/jobs/batch-ai/estimate`, Try it from `/preview`, and Queue posts to
|
|
142
|
+
* `/jobs/batch-ai`. Radix traps focus and closes on Esc, except while the
|
|
143
|
+
* job is being queued.
|
|
144
|
+
*/
|
|
145
|
+
export function NewBatchJob({
|
|
146
|
+
role,
|
|
147
|
+
config,
|
|
148
|
+
selection = null,
|
|
149
|
+
operation: initialOperation,
|
|
150
|
+
onClose,
|
|
151
|
+
onQueued,
|
|
152
|
+
onFocusFallback,
|
|
153
|
+
}: NewBatchJobProps) {
|
|
154
|
+
const ids = useId()
|
|
155
|
+
const sources = useMemo(() => batchSources(config), [config])
|
|
156
|
+
const admin = isAdminRole(role)
|
|
157
|
+
|
|
158
|
+
const [step, setStep] = useState(0)
|
|
159
|
+
const [operation, setOperation] = useState<BatchOperation>(
|
|
160
|
+
() => initialOperation ?? (selection?.source === MEDIA_SOURCE.id ? 'alt' : 'meta'),
|
|
161
|
+
)
|
|
162
|
+
const [instruction, setInstruction] = useState('')
|
|
163
|
+
const [field, setField] = useState<BatchAiTextField>('metaDescription')
|
|
164
|
+
const [collection, setCollection] = useState(() =>
|
|
165
|
+
selection && selection.source !== MEDIA_SOURCE.id ? selection.source : (sources[0]?.id ?? ''),
|
|
166
|
+
)
|
|
167
|
+
// Picked documents run as they are, drafts included.
|
|
168
|
+
const [statusFilter, setStatusFilter] = useState<'published' | 'all'>(
|
|
169
|
+
selection ? 'all' : 'published',
|
|
170
|
+
)
|
|
171
|
+
const [review, setReview] = useState<'review' | 'auto'>('review')
|
|
172
|
+
const [onApprove, setOnApprove] = useState<'draft' | 'publish'>('draft')
|
|
173
|
+
const [instructionTouched, setInstructionTouched] = useState(false)
|
|
174
|
+
const [estimate, setEstimate] = useState<EstimateState | null>(null)
|
|
175
|
+
const [estimateRun, setEstimateRun] = useState(0)
|
|
176
|
+
const [preview, setPreview] = useState<PreviewState | null>(null)
|
|
177
|
+
const [creating, setCreating] = useState(false)
|
|
178
|
+
const [createError, setCreateError] = useState<string | null>(null)
|
|
179
|
+
const [announce, setAnnounce] = useState('')
|
|
180
|
+
|
|
181
|
+
const queued = useRef<Job | null>(null)
|
|
182
|
+
const previewAbort = useRef<AbortController | null>(null)
|
|
183
|
+
const body = useRef<HTMLDivElement>(null)
|
|
184
|
+
const instructionRef = useRef<HTMLTextAreaElement>(null)
|
|
185
|
+
const stepMoved = useRef(false)
|
|
186
|
+
const returnFocus = useReturnFocus(() => onFocusFallback?.(null))
|
|
187
|
+
|
|
188
|
+
const op = batchOp(operation)
|
|
189
|
+
const onMedia = runsOnMedia(operation)
|
|
190
|
+
const draft: BatchDraft = {
|
|
191
|
+
operation,
|
|
192
|
+
instruction,
|
|
193
|
+
field,
|
|
194
|
+
collection,
|
|
195
|
+
statusFilter,
|
|
196
|
+
review,
|
|
197
|
+
onApprove,
|
|
198
|
+
selection,
|
|
199
|
+
}
|
|
200
|
+
const request = batchRequest(draft)
|
|
201
|
+
const eKey = estimateKey(request)
|
|
202
|
+
const pKey = previewKey(request)
|
|
203
|
+
const sourceId = request.source
|
|
204
|
+
const sourceLabel =
|
|
205
|
+
sourceId === MEDIA_SOURCE.id
|
|
206
|
+
? MEDIA_SOURCE.label
|
|
207
|
+
: (sources.find((s) => s.id === sourceId)?.label ?? sourceId)
|
|
208
|
+
const instructionProblem = instructionError(operation, instruction)
|
|
209
|
+
const showInstructionError = instructionTouched && instructionProblem !== null
|
|
210
|
+
|
|
211
|
+
const current = estimate?.key === eKey ? estimate : null
|
|
212
|
+
const counted = current?.value ?? null
|
|
213
|
+
const problem = counted ? matchProblem(counted) : null
|
|
214
|
+
const canQueue = counted !== null && problem === null
|
|
215
|
+
const shownPreview = preview?.key === pKey ? preview : null
|
|
216
|
+
|
|
217
|
+
// The estimate, read when step 2 opens and again whenever what it counts changes.
|
|
218
|
+
const counting = step > 0
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!counting) return
|
|
221
|
+
const controller = new AbortController()
|
|
222
|
+
const key = eKey
|
|
223
|
+
setEstimate((prev) => ({
|
|
224
|
+
key,
|
|
225
|
+
value: prev?.key === key ? prev.value : null,
|
|
226
|
+
loading: true,
|
|
227
|
+
error: null,
|
|
228
|
+
}))
|
|
229
|
+
void estimateBatchAi(request, controller.signal).then((res) => {
|
|
230
|
+
if (controller.signal.aborted) return
|
|
231
|
+
setEstimate((prev) => ({
|
|
232
|
+
key,
|
|
233
|
+
value: res.ok ? res.value : prev?.key === key ? prev.value : null,
|
|
234
|
+
loading: false,
|
|
235
|
+
error: res.ok ? null : res.error,
|
|
236
|
+
}))
|
|
237
|
+
})
|
|
238
|
+
return () => controller.abort()
|
|
239
|
+
// `eKey` stands for what changes the count: operation, source, status and
|
|
240
|
+
// picked ids. The instruction and field can only change on step 1.
|
|
241
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
242
|
+
}, [counting, eKey, estimateRun])
|
|
243
|
+
|
|
244
|
+
useEffect(() => () => previewAbort.current?.abort(), [])
|
|
245
|
+
|
|
246
|
+
// A new step: focus its first control and say which step it is.
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!stepMoved.current) return
|
|
249
|
+
stepMoved.current = false
|
|
250
|
+
body.current?.scrollTo?.({ top: 0 })
|
|
251
|
+
focusFirstControl(body.current)
|
|
252
|
+
setAnnounce(`Step ${step + 1} of ${STEPS.length}: ${STEPS[step]}`)
|
|
253
|
+
}, [step])
|
|
254
|
+
|
|
255
|
+
function goTo(next: number) {
|
|
256
|
+
stepMoved.current = true
|
|
257
|
+
setCreateError(null)
|
|
258
|
+
setStep(next)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pickOperation(next: BatchOperation) {
|
|
262
|
+
setOperation(next)
|
|
263
|
+
if (next !== 'custom') setInstructionTouched(false)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function pickReview(next: 'review' | 'auto') {
|
|
267
|
+
setReview(next)
|
|
268
|
+
// "Apply automatically" keeps each document's status, so it never publishes.
|
|
269
|
+
if (next === 'auto') setOnApprove('draft')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function onContinue() {
|
|
273
|
+
if (step === 0) {
|
|
274
|
+
if (instructionProblem) {
|
|
275
|
+
setInstructionTouched(true)
|
|
276
|
+
instructionRef.current?.focus()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
goTo(1)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (canQueue) goTo(2)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function tryIt() {
|
|
286
|
+
previewAbort.current?.abort()
|
|
287
|
+
const controller = new AbortController()
|
|
288
|
+
previewAbort.current = controller
|
|
289
|
+
const key = pKey
|
|
290
|
+
setPreview((prev) => ({
|
|
291
|
+
key,
|
|
292
|
+
items: prev?.key === key ? prev.items : null,
|
|
293
|
+
loading: true,
|
|
294
|
+
error: null,
|
|
295
|
+
}))
|
|
296
|
+
const res = await previewBatchAi(request, controller.signal)
|
|
297
|
+
if (controller.signal.aborted) return
|
|
298
|
+
setPreview({
|
|
299
|
+
key,
|
|
300
|
+
items: res.ok ? res.value : null,
|
|
301
|
+
loading: false,
|
|
302
|
+
error: res.ok ? null : res.error,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function queue() {
|
|
307
|
+
setCreating(true)
|
|
308
|
+
setCreateError(null)
|
|
309
|
+
const res = await createBatchAiJob(request)
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
setCreating(false)
|
|
312
|
+
setCreateError(res.error)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
queued.current = res.value
|
|
316
|
+
previewAbort.current?.abort()
|
|
317
|
+
onQueued(res.value)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function close() {
|
|
321
|
+
if (creating) return
|
|
322
|
+
onClose()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const opsName = `${ids}-op`
|
|
326
|
+
const errorId = `${ids}-ins-err`
|
|
327
|
+
const autoTipId = `${ids}-auto-tip`
|
|
328
|
+
const publishHelpId = `${ids}-pub-help`
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<Dialog.Root open onOpenChange={(open) => (open ? undefined : close())}>
|
|
332
|
+
<Dialog.Portal container={adminPortalContainer()}>
|
|
333
|
+
<Dialog.Overlay className="bg-scrim fixed inset-0 z-[var(--z-modal)]" />
|
|
334
|
+
<Dialog.Content
|
|
335
|
+
onOpenAutoFocus={(event) => {
|
|
336
|
+
returnFocus.onOpenAutoFocus()
|
|
337
|
+
event.preventDefault()
|
|
338
|
+
focusFirstControl(body.current)
|
|
339
|
+
}}
|
|
340
|
+
onCloseAutoFocus={(event) => {
|
|
341
|
+
const job = queued.current
|
|
342
|
+
if (job && onFocusFallback) {
|
|
343
|
+
event.preventDefault()
|
|
344
|
+
onFocusFallback(job)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
returnFocus.onCloseAutoFocus(event)
|
|
348
|
+
}}
|
|
349
|
+
// Radix makes the rest of the page inert but doesn't say so on the dialog.
|
|
350
|
+
aria-modal="true"
|
|
351
|
+
aria-describedby={undefined}
|
|
352
|
+
data-batch-wizard=""
|
|
353
|
+
className="bg-card border-bdr rounded-14 shadow-shd2 mobile:inset-0 mobile:h-full mobile:max-h-none mobile:w-full mobile:translate-x-0 mobile:translate-y-0 mobile:rounded-none mobile:border-0 mobile:shadow-none fixed top-1/2 left-1/2 z-[var(--z-modal)] flex max-h-[calc(100vh-32px)] w-[min(680px,calc(100%-32px))] -translate-x-1/2 -translate-y-1/2 flex-col border leading-[normal] outline-none"
|
|
354
|
+
>
|
|
355
|
+
<div className="border-bdr mobile:pt-[max(16px,env(safe-area-inset-top,0px))] flex items-center gap-3 border-b px-5 pt-4 pb-3">
|
|
356
|
+
<span className="text-acc flex">
|
|
357
|
+
<JobIcons.spark size={16} />
|
|
358
|
+
</span>
|
|
359
|
+
<Dialog.Title className="text-txt m-0 min-w-0 flex-1 text-[length:var(--fs-drawer)] leading-[normal] font-bold">
|
|
360
|
+
New batch AI job
|
|
361
|
+
</Dialog.Title>
|
|
362
|
+
<Dialog.Close asChild>
|
|
363
|
+
<Button
|
|
364
|
+
variant="icon"
|
|
365
|
+
size="icon"
|
|
366
|
+
aria-label="Close"
|
|
367
|
+
aria-disabled={creating || undefined}
|
|
368
|
+
className="aria-disabled:opacity-50"
|
|
369
|
+
>
|
|
370
|
+
<JobIcons.x size={15} />
|
|
371
|
+
</Button>
|
|
372
|
+
</Dialog.Close>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<StepBar step={step} />
|
|
376
|
+
|
|
377
|
+
<div
|
|
378
|
+
ref={body}
|
|
379
|
+
data-batch-step={step + 1}
|
|
380
|
+
className="flex min-h-0 flex-1 flex-col gap-3.5 overflow-auto px-5 py-4 *:shrink-0"
|
|
381
|
+
>
|
|
382
|
+
{step === 0 ? (
|
|
383
|
+
<>
|
|
384
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
385
|
+
<legend className="sr-only">Operation</legend>
|
|
386
|
+
<div className="grid grid-cols-2 gap-2 max-[560px]:grid-cols-1">
|
|
387
|
+
{JOB_OPS.map((x) => {
|
|
388
|
+
const fits = operationFits(x.id, selection)
|
|
389
|
+
return (
|
|
390
|
+
<label
|
|
391
|
+
key={x.id}
|
|
392
|
+
className="rounded-10 border-bdr bg-card has-checked:border-acc has-focus-visible:outline-acc flex cursor-pointer flex-col gap-[3px] border px-3 py-2.5 text-left leading-[normal] font-normal has-checked:shadow-[0_0_0_3px_var(--acc-l)] has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-disabled:cursor-not-allowed has-disabled:opacity-55"
|
|
393
|
+
>
|
|
394
|
+
<input
|
|
395
|
+
type="radio"
|
|
396
|
+
name={opsName}
|
|
397
|
+
value={x.id}
|
|
398
|
+
checked={operation === x.id}
|
|
399
|
+
disabled={!fits}
|
|
400
|
+
onChange={() => pickOperation(x.id)}
|
|
401
|
+
className="sr-only"
|
|
402
|
+
/>
|
|
403
|
+
<b className="text-body text-txt font-semibold">{x.label}</b>
|
|
404
|
+
<span className="text-meta text-sub leading-[1.45]">{x.d}</span>
|
|
405
|
+
</label>
|
|
406
|
+
)
|
|
407
|
+
})}
|
|
408
|
+
</div>
|
|
409
|
+
</fieldset>
|
|
410
|
+
{operation === 'custom' ? (
|
|
411
|
+
<div>
|
|
412
|
+
<label className={LABEL} htmlFor={`${ids}-field`}>
|
|
413
|
+
Field
|
|
414
|
+
</label>
|
|
415
|
+
<select
|
|
416
|
+
id={`${ids}-field`}
|
|
417
|
+
className={`${FIELD} h-9`}
|
|
418
|
+
value={field}
|
|
419
|
+
onChange={(event) => setField(event.target.value as BatchAiTextField)}
|
|
420
|
+
>
|
|
421
|
+
{CUSTOM_FIELDS.map((f) => (
|
|
422
|
+
<option key={f.id} value={f.id}>
|
|
423
|
+
{f.label}
|
|
424
|
+
</option>
|
|
425
|
+
))}
|
|
426
|
+
</select>
|
|
427
|
+
</div>
|
|
428
|
+
) : null}
|
|
429
|
+
<div>
|
|
430
|
+
<label className={LABEL} htmlFor={`${ids}-ins`}>
|
|
431
|
+
{operation === 'custom' ? 'Instruction' : 'Extra guidance'}
|
|
432
|
+
{operation !== 'custom' ? (
|
|
433
|
+
<span className="text-faint font-normal"> (optional)</span>
|
|
434
|
+
) : null}
|
|
435
|
+
</label>
|
|
436
|
+
<textarea
|
|
437
|
+
id={`${ids}-ins`}
|
|
438
|
+
ref={instructionRef}
|
|
439
|
+
className={`${FIELD} block min-h-16 resize-y py-2 ${
|
|
440
|
+
showInstructionError ? 'border-err focus:border-err' : ''
|
|
441
|
+
}`}
|
|
442
|
+
value={instruction}
|
|
443
|
+
maxLength={INSTRUCTION_MAX}
|
|
444
|
+
aria-invalid={showInstructionError || undefined}
|
|
445
|
+
aria-describedby={showInstructionError ? errorId : undefined}
|
|
446
|
+
onChange={(event) => setInstruction(event.target.value)}
|
|
447
|
+
placeholder={
|
|
448
|
+
operation === 'custom'
|
|
449
|
+
? 'e.g. Translate the excerpt to Spanish. Keep brand names in English.'
|
|
450
|
+
: 'e.g. Plain and specific. Mention Hampton Roads only on local posts.'
|
|
451
|
+
}
|
|
452
|
+
/>
|
|
453
|
+
{showInstructionError ? (
|
|
454
|
+
<p id={errorId} className="text-hint text-err m-0 mt-1">
|
|
455
|
+
{instructionProblem}
|
|
456
|
+
</p>
|
|
457
|
+
) : null}
|
|
458
|
+
{/* 9px: the kit's textarea is inline, so its line adds 5px to the help's 4px margin. */}
|
|
459
|
+
<div className={`${HELP} mt-[9px]`}>
|
|
460
|
+
The AI also follows your brand voice from Settings → AI.
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
</>
|
|
464
|
+
) : null}
|
|
465
|
+
|
|
466
|
+
{step === 1 ? (
|
|
467
|
+
<>
|
|
468
|
+
<div className="grid grid-cols-2 gap-2.5 max-[560px]:grid-cols-1">
|
|
469
|
+
<div className="min-w-0">
|
|
470
|
+
<label className={LABEL} htmlFor={`${ids}-src`}>
|
|
471
|
+
Content
|
|
472
|
+
</label>
|
|
473
|
+
<select
|
|
474
|
+
id={`${ids}-src`}
|
|
475
|
+
className={`${FIELD} h-9`}
|
|
476
|
+
value={sourceId}
|
|
477
|
+
disabled={onMedia || selection !== null}
|
|
478
|
+
onChange={(event) => setCollection(event.target.value)}
|
|
479
|
+
>
|
|
480
|
+
{onMedia || selection ? (
|
|
481
|
+
<option value={sourceId}>{sourceLabel}</option>
|
|
482
|
+
) : (
|
|
483
|
+
sources.map((s) => (
|
|
484
|
+
<option key={s.id} value={s.id}>
|
|
485
|
+
{s.label}
|
|
486
|
+
</option>
|
|
487
|
+
))
|
|
488
|
+
)}
|
|
489
|
+
</select>
|
|
490
|
+
</div>
|
|
491
|
+
<div className="min-w-0">
|
|
492
|
+
<label className={LABEL} htmlFor={`${ids}-pub`}>
|
|
493
|
+
Status
|
|
494
|
+
</label>
|
|
495
|
+
{/* Images have no publish state. */}
|
|
496
|
+
<select
|
|
497
|
+
id={`${ids}-pub`}
|
|
498
|
+
className={`${FIELD} h-9`}
|
|
499
|
+
value={statusFilter}
|
|
500
|
+
disabled={onMedia}
|
|
501
|
+
onChange={(event) =>
|
|
502
|
+
setStatusFilter(event.target.value as 'published' | 'all')
|
|
503
|
+
}
|
|
504
|
+
>
|
|
505
|
+
<option value="published">Published only</option>
|
|
506
|
+
<option value="all">Published and drafts</option>
|
|
507
|
+
</select>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
<OnlyWhere>
|
|
511
|
+
{selection ? (
|
|
512
|
+
<b className="text-txt">{selection.documentIds.length} selected</b>
|
|
513
|
+
) : (
|
|
514
|
+
<>
|
|
515
|
+
<span>
|
|
516
|
+
{operation === 'custom'
|
|
517
|
+
? (CUSTOM_FIELDS.find((f) => f.id === field)?.label ?? op.field)
|
|
518
|
+
: op.field}
|
|
519
|
+
:
|
|
520
|
+
</span>
|
|
521
|
+
<b className="text-txt">{op.filter}</b>
|
|
522
|
+
</>
|
|
523
|
+
)}
|
|
524
|
+
</OnlyWhere>
|
|
525
|
+
<MatchBox
|
|
526
|
+
estimate={current}
|
|
527
|
+
sourceLabel={sourceLabel}
|
|
528
|
+
problem={problem}
|
|
529
|
+
onRetry={() => setEstimateRun((n) => n + 1)}
|
|
530
|
+
/>
|
|
531
|
+
</>
|
|
532
|
+
) : null}
|
|
533
|
+
|
|
534
|
+
{step === 2 && counted ? (
|
|
535
|
+
<>
|
|
536
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
537
|
+
<legend className={LABEL}>Before anything changes</legend>
|
|
538
|
+
<div className="flex flex-col gap-1.5">
|
|
539
|
+
<label className={CARD}>
|
|
540
|
+
<input
|
|
541
|
+
type="radio"
|
|
542
|
+
name={`${ids}-r`}
|
|
543
|
+
className={RADIO}
|
|
544
|
+
checked={review === 'review'}
|
|
545
|
+
onChange={() => pickReview('review')}
|
|
546
|
+
/>
|
|
547
|
+
<span>
|
|
548
|
+
Review each change
|
|
549
|
+
<em className="text-count text-acc ml-1.5 font-bold not-italic">
|
|
550
|
+
RECOMMENDED
|
|
551
|
+
</em>
|
|
552
|
+
<small className={SMALL}>
|
|
553
|
+
Suggestions wait in the job’s Review tab. Approve, edit or reject each
|
|
554
|
+
one.
|
|
555
|
+
</small>
|
|
556
|
+
</span>
|
|
557
|
+
</label>
|
|
558
|
+
{/* The tooltip sits beside the label, so the disabled card's dimming doesn't reach it. */}
|
|
559
|
+
<div className="group relative">
|
|
560
|
+
<label className={CARD}>
|
|
561
|
+
<input
|
|
562
|
+
type="radio"
|
|
563
|
+
name={`${ids}-r`}
|
|
564
|
+
className={RADIO}
|
|
565
|
+
checked={review === 'auto'}
|
|
566
|
+
disabled={!admin}
|
|
567
|
+
aria-describedby={admin ? undefined : autoTipId}
|
|
568
|
+
onChange={() => pickReview('auto')}
|
|
569
|
+
/>
|
|
570
|
+
<span>
|
|
571
|
+
Apply automatically
|
|
572
|
+
<small className={SMALL}>
|
|
573
|
+
Admins only. Every change is saved to the document’s history, so it can
|
|
574
|
+
be undone.
|
|
575
|
+
</small>
|
|
576
|
+
</span>
|
|
577
|
+
</label>
|
|
578
|
+
{admin ? null : (
|
|
579
|
+
<span
|
|
580
|
+
role="tooltip"
|
|
581
|
+
id={autoTipId}
|
|
582
|
+
className="rounded-8 bg-txt text-sb text-hint shadow-shd2 pointer-events-none invisible absolute bottom-[calc(100%+6px)] left-3 z-[1] px-2.5 py-2 leading-[1.55] opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
|
583
|
+
>
|
|
584
|
+
{AUTO_ADMIN_ONLY}
|
|
585
|
+
</span>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</fieldset>
|
|
590
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
591
|
+
<legend className={LABEL}>
|
|
592
|
+
{review === 'review' ? 'When a change is approved' : 'When a change is made'}
|
|
593
|
+
</legend>
|
|
594
|
+
<div className="flex flex-row flex-wrap gap-1.5">
|
|
595
|
+
{(
|
|
596
|
+
[
|
|
597
|
+
['draft', 'Save as draft', 'Someone publishes later'],
|
|
598
|
+
['publish', 'Publish right away', 'Goes live on the site'],
|
|
599
|
+
] as const
|
|
600
|
+
).map(([k, l, s]) => (
|
|
601
|
+
<label key={k} className={`${CARD} flex-[1_1_200px]`}>
|
|
602
|
+
<input
|
|
603
|
+
type="radio"
|
|
604
|
+
name={`${ids}-a`}
|
|
605
|
+
className={RADIO}
|
|
606
|
+
checked={onApprove === k}
|
|
607
|
+
disabled={k === 'publish' && review === 'auto'}
|
|
608
|
+
aria-describedby={
|
|
609
|
+
k === 'publish' && review === 'auto' ? publishHelpId : undefined
|
|
610
|
+
}
|
|
611
|
+
onChange={() => setOnApprove(k)}
|
|
612
|
+
/>
|
|
613
|
+
<span>
|
|
614
|
+
{l}
|
|
615
|
+
<small className={SMALL}>{s}</small>
|
|
616
|
+
</span>
|
|
617
|
+
</label>
|
|
618
|
+
))}
|
|
619
|
+
</div>
|
|
620
|
+
{review === 'auto' ? (
|
|
621
|
+
<p id={publishHelpId} className={`${HELP} mt-1`}>
|
|
622
|
+
{AUTO_KEEPS_STATUS}
|
|
623
|
+
</p>
|
|
624
|
+
) : null}
|
|
625
|
+
</fieldset>
|
|
626
|
+
<EstimateTiles estimate={counted} />
|
|
627
|
+
<div>
|
|
628
|
+
<div className="mb-1.5 flex items-center gap-2">
|
|
629
|
+
<span className={`${LABEL_TEXT} flex-1`}>Preview on 3 documents</span>
|
|
630
|
+
<BusyButton
|
|
631
|
+
variant="secondary"
|
|
632
|
+
size="sm"
|
|
633
|
+
busy={shownPreview?.loading ?? false}
|
|
634
|
+
onClick={() => void tryIt()}
|
|
635
|
+
>
|
|
636
|
+
{shownPreview?.loading
|
|
637
|
+
? 'Writing…'
|
|
638
|
+
: shownPreview?.items || shownPreview?.error
|
|
639
|
+
? 'Try again'
|
|
640
|
+
: 'Try it'}
|
|
641
|
+
</BusyButton>
|
|
642
|
+
</div>
|
|
643
|
+
<PreviewList preview={shownPreview} operation={operation} />
|
|
644
|
+
</div>
|
|
645
|
+
</>
|
|
646
|
+
) : null}
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<div className="border-bdr mobile:pb-[calc(12px+env(safe-area-inset-bottom,0px))] flex flex-wrap items-center gap-2 border-t px-5 py-3">
|
|
650
|
+
{step > 0 ? (
|
|
651
|
+
<Button
|
|
652
|
+
variant="ghost"
|
|
653
|
+
aria-disabled={creating || undefined}
|
|
654
|
+
className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
|
|
655
|
+
onClick={() => (creating ? undefined : goTo(step - 1))}
|
|
656
|
+
>
|
|
657
|
+
Back
|
|
658
|
+
</Button>
|
|
659
|
+
) : (
|
|
660
|
+
<Button variant="ghost" onClick={close}>
|
|
661
|
+
Cancel
|
|
662
|
+
</Button>
|
|
663
|
+
)}
|
|
664
|
+
{createError ? (
|
|
665
|
+
<p role="alert" className="text-meta text-err m-0 min-w-0 flex-1 text-right">
|
|
666
|
+
Couldn’t queue the job: {createError}
|
|
667
|
+
</p>
|
|
668
|
+
) : (
|
|
669
|
+
<span className="flex-1" />
|
|
670
|
+
)}
|
|
671
|
+
{step < 2 ? (
|
|
672
|
+
<Button
|
|
673
|
+
aria-disabled={(step === 0 ? instructionProblem !== null : !canQueue) || undefined}
|
|
674
|
+
className="aria-disabled:opacity-50"
|
|
675
|
+
onClick={onContinue}
|
|
676
|
+
>
|
|
677
|
+
Continue
|
|
678
|
+
</Button>
|
|
679
|
+
) : (
|
|
680
|
+
<BusyButton
|
|
681
|
+
busy={creating}
|
|
682
|
+
leftIcon={<JobIcons.spark size={13} />}
|
|
683
|
+
onClick={() => void queue()}
|
|
684
|
+
>
|
|
685
|
+
Queue {counted?.count ?? 0} documents
|
|
686
|
+
</BusyButton>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
690
|
+
{announce}
|
|
691
|
+
</div>
|
|
692
|
+
</Dialog.Content>
|
|
693
|
+
</Dialog.Portal>
|
|
694
|
+
</Dialog.Root>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Focus a step's first control: the checked radio of a group, else the first enabled field. */
|
|
699
|
+
function focusFirstControl(root: HTMLElement | null) {
|
|
700
|
+
if (!root) return
|
|
701
|
+
const target = Array.from(
|
|
702
|
+
root.querySelectorAll<HTMLElement>('input, select, textarea, button'),
|
|
703
|
+
).find((el) => {
|
|
704
|
+
if ((el as HTMLInputElement).disabled) return false
|
|
705
|
+
if (el instanceof HTMLInputElement && el.type === 'radio') return el.checked
|
|
706
|
+
return true
|
|
707
|
+
})
|
|
708
|
+
;(target ?? root).focus()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** `.nb-match`: the count, "one AI call per document", 3 sample rows and "and {n} more". */
|
|
712
|
+
function MatchBox({
|
|
713
|
+
estimate,
|
|
714
|
+
sourceLabel,
|
|
715
|
+
problem,
|
|
716
|
+
onRetry,
|
|
717
|
+
}: {
|
|
718
|
+
estimate: EstimateState | null
|
|
719
|
+
sourceLabel: string
|
|
720
|
+
problem: string | null
|
|
721
|
+
onRetry: () => void
|
|
722
|
+
}) {
|
|
723
|
+
const value = estimate?.value ?? null
|
|
724
|
+
const row = 'border-bdr text-sm2 text-txt flex min-w-0 gap-2.5 border-t px-3 py-[7px]'
|
|
725
|
+
return (
|
|
726
|
+
<div className="rounded-10 border-bdr overflow-hidden border" aria-busy={estimate?.loading}>
|
|
727
|
+
<div className="bg-bg text-sm2 text-sub flex items-center gap-2 px-3 py-[9px]">
|
|
728
|
+
{value ? (
|
|
729
|
+
<>
|
|
730
|
+
<b className="text-figure text-txt">{value.count.toLocaleString('en-US')}</b>
|
|
731
|
+
<span>{sourceLabel.toLowerCase()} match</span>
|
|
732
|
+
{estimate?.loading ? <Spinner /> : null}
|
|
733
|
+
<span className="ml-auto text-right">one AI call per document</span>
|
|
734
|
+
</>
|
|
735
|
+
) : estimate?.error ? (
|
|
736
|
+
<>
|
|
737
|
+
<span role="alert" className="text-err min-w-0 flex-1">
|
|
738
|
+
Couldn’t count the documents: {estimate.error}
|
|
739
|
+
</span>
|
|
740
|
+
<Button variant="secondary" size="sm" onClick={onRetry}>
|
|
741
|
+
Try again
|
|
742
|
+
</Button>
|
|
743
|
+
</>
|
|
744
|
+
) : (
|
|
745
|
+
<span role="status" className="flex items-center gap-2">
|
|
746
|
+
{/* The count's 14px line, so the header keeps its height once it loads. */}
|
|
747
|
+
<b className="text-figure">
|
|
748
|
+
<Spinner />
|
|
749
|
+
</b>
|
|
750
|
+
Counting documents…
|
|
751
|
+
</span>
|
|
752
|
+
)}
|
|
753
|
+
</div>
|
|
754
|
+
{value && estimate?.error ? (
|
|
755
|
+
<div role="alert" className={`${row} text-err`}>
|
|
756
|
+
Couldn’t update the count: {estimate.error}
|
|
757
|
+
<Button variant="link" size="sm" className="ml-auto" onClick={onRetry}>
|
|
758
|
+
Try again
|
|
759
|
+
</Button>
|
|
760
|
+
</div>
|
|
761
|
+
) : null}
|
|
762
|
+
{value && problem ? (
|
|
763
|
+
<div role="alert" className={`${row} text-err`}>
|
|
764
|
+
{problem}
|
|
765
|
+
</div>
|
|
766
|
+
) : null}
|
|
767
|
+
{value && !problem
|
|
768
|
+
? value.sample.map((s, i) => (
|
|
769
|
+
<div key={`${s.url}|${i}`} className={row}>
|
|
770
|
+
<span className="min-w-0 truncate">{s.title}</span>
|
|
771
|
+
{s.url ? (
|
|
772
|
+
<code className="text-faint text-micro ml-auto max-w-[55%] shrink-0 truncate font-mono">
|
|
773
|
+
{s.url}
|
|
774
|
+
</code>
|
|
775
|
+
) : null}
|
|
776
|
+
</div>
|
|
777
|
+
))
|
|
778
|
+
: null}
|
|
779
|
+
{value && !problem && value.count > value.sample.length ? (
|
|
780
|
+
<div className={`${row} text-faint`}>
|
|
781
|
+
and {(value.count - value.sample.length).toLocaleString('en-US')} more
|
|
782
|
+
</div>
|
|
783
|
+
) : null}
|
|
784
|
+
</div>
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** `.nb-est`: Documents, Estimated time and AI usage, then the pace and the limit's share. */
|
|
789
|
+
function EstimateTiles({ estimate }: { estimate: BatchAiEstimate }) {
|
|
790
|
+
const tiles = estimateTiles(estimate)
|
|
791
|
+
const cell = 'rounded-10 border-bdr text-hint text-faint min-w-0 border px-3 py-[9px]'
|
|
792
|
+
const value = 'text-figure text-txt m-0 mt-px block'
|
|
793
|
+
return (
|
|
794
|
+
<div className="flex flex-col gap-2">
|
|
795
|
+
<dl className="m-0 grid grid-cols-3 gap-2 max-[560px]:grid-cols-1">
|
|
796
|
+
<div className={cell}>
|
|
797
|
+
<dt>Documents</dt>
|
|
798
|
+
<dd className={value}>{estimate.count.toLocaleString('en-US')}</dd>
|
|
799
|
+
</div>
|
|
800
|
+
<div className={cell}>
|
|
801
|
+
<dt>Estimated time</dt>
|
|
802
|
+
<dd className={value}>{tiles.minutes}</dd>
|
|
803
|
+
</div>
|
|
804
|
+
<div className={cell}>
|
|
805
|
+
<dt>AI usage</dt>
|
|
806
|
+
<dd className={value}>{tiles.tokens}</dd>
|
|
807
|
+
</div>
|
|
808
|
+
</dl>
|
|
809
|
+
<p className={`${HELP} m-0`}>{estimateNote(estimate)}</p>
|
|
810
|
+
</div>
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** `.nb-pv`: the sample outputs, or what to expect before the first try. */
|
|
815
|
+
function PreviewList({
|
|
816
|
+
preview,
|
|
817
|
+
operation,
|
|
818
|
+
}: {
|
|
819
|
+
preview: PreviewState | null
|
|
820
|
+
operation: BatchOperation
|
|
821
|
+
}) {
|
|
822
|
+
if (preview?.error) {
|
|
823
|
+
return (
|
|
824
|
+
<p role="alert" className="text-meta text-err m-0">
|
|
825
|
+
Couldn’t write a preview: {preview.error}
|
|
826
|
+
</p>
|
|
827
|
+
)
|
|
828
|
+
}
|
|
829
|
+
if (preview?.items) {
|
|
830
|
+
if (preview.items.length === 0) {
|
|
831
|
+
return (
|
|
832
|
+
<p role="status" className={`${HELP} m-0`}>
|
|
833
|
+
The AI would leave the first 3 documents as they are.
|
|
834
|
+
</p>
|
|
835
|
+
)
|
|
836
|
+
}
|
|
837
|
+
return (
|
|
838
|
+
<ul role="list" className="m-0 flex list-none flex-col gap-1.5 p-0">
|
|
839
|
+
{preview.items.map((item, i) => (
|
|
840
|
+
<li
|
|
841
|
+
key={`${item.documentId}|${i}`}
|
|
842
|
+
className="rounded-9 border-bdr text-sm2 text-txt border px-3 py-[9px] leading-[1.5] break-words"
|
|
843
|
+
>
|
|
844
|
+
<small className="text-hint text-faint mb-0.5 block">{item.title}</small>
|
|
845
|
+
{operation === 'links' ? <LinkLine after={item.after} /> : item.after}
|
|
846
|
+
</li>
|
|
847
|
+
))}
|
|
848
|
+
</ul>
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
if (preview?.loading) return null
|
|
852
|
+
return (
|
|
853
|
+
<p className={`${HELP} m-0`}>
|
|
854
|
+
See sample output before you queue the whole batch. Previews don’t change anything.
|
|
855
|
+
</p>
|
|
856
|
+
)
|
|
857
|
+
}
|