@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.
Files changed (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -77,6 +77,8 @@ export interface WorkerStripProps {
77
77
  rows: JobRow[]
78
78
  /** Live is on and the tab is visible: the clock ticks every second. */
79
79
  ticking: boolean
80
+ /** The viewer may start a scan. Viewers get no Run now (the server refuses it). */
81
+ canRun: boolean
80
82
  onRunNow: (type: RunnableJobType) => Promise<void>
81
83
  }
82
84
 
@@ -91,6 +93,7 @@ export function WorkerStrip({
91
93
  loading,
92
94
  rows,
93
95
  ticking,
96
+ canRun,
94
97
  onRunNow,
95
98
  }: WorkerStripProps) {
96
99
  const now = useNow(ticking) + skewMs
@@ -125,17 +128,19 @@ export function WorkerStrip({
125
128
  <Text label={JOB_TYPES[type].label}>
126
129
  {when} {next ? <small>· {next}</small> : null}
127
130
  </Text>
128
- <BusyButton
129
- variant="secondary"
130
- size="sm"
131
- className="ml-auto"
132
- leftIcon={<JobIcons.play size={11} />}
133
- busy={busy === type}
134
- aria-label={`Run now: ${JOB_TYPES[type].label}`}
135
- onClick={() => void run(type)}
136
- >
137
- Run now
138
- </BusyButton>
131
+ {canRun ? (
132
+ <BusyButton
133
+ variant="secondary"
134
+ size="sm"
135
+ className="ml-auto"
136
+ leftIcon={<JobIcons.play size={11} />}
137
+ busy={busy === type}
138
+ aria-label={`Run now: ${JOB_TYPES[type].label}`}
139
+ onClick={() => void run(type)}
140
+ >
141
+ Run now
142
+ </BusyButton>
143
+ ) : null}
139
144
  </Cell>
140
145
  )
141
146
  })}
@@ -31,6 +31,17 @@ export function focusJobRow(id: string): void {
31
31
  else focusJobsFallback()
32
32
  }
33
33
 
34
+ /**
35
+ * Focus the open drawer's tab panel, where the job's new state shows, when a
36
+ * footer action's button went away (a cancel, a retry). The row is behind the
37
+ * drawer, which keeps focus inside itself.
38
+ */
39
+ export function focusJobDrawer(): void {
40
+ const drawer = document.querySelector<HTMLElement>('[data-job-drawer]')
41
+ const target = drawer?.querySelector<HTMLElement>('[role="tabpanel"]') ?? drawer
42
+ target?.focus()
43
+ }
44
+
34
45
  /**
35
46
  * Focus return for the Jobs dialogs. Radix's modal Dialog only returns focus
36
47
  * to a `Dialog.Trigger`, and these open from several controls, so the control
@@ -8,12 +8,17 @@ import { Segmented } from '../components/ui/Segmented.js'
8
8
  import {
9
9
  JOB_FILTERS,
10
10
  JOB_TYPES,
11
+ canActOnJobs,
12
+ canRetryJob,
11
13
  countJobs,
12
- jobType,
14
+ jobActions,
15
+ jobTotal,
13
16
  matchJob,
17
+ retryPoint,
14
18
  toJobRows,
15
19
  type JobFilter,
16
20
  type JobRow,
21
+ type JobViewer,
17
22
  } from '../lib/job-view.js'
18
23
  import {
19
24
  JOBS_LIST_LIMIT,
@@ -24,20 +29,35 @@ import {
24
29
  } from '../lib/jobs-service.js'
25
30
  import { BusyButton } from './Jobs/BusyButton.js'
26
31
  import { CancelJobDialog, staleRunning } from './Jobs/CancelJobDialog.js'
27
- import { focusJobRow } from './Jobs/focus.js'
32
+ import { focusJobDrawer, focusJobRow } from './Jobs/focus.js'
28
33
  import { JobsTable, type JobDrawerTab } from './Jobs/JobsTable.js'
29
34
  import { JobIcons } from './Jobs/icons.js'
30
- import { JobDrawer } from './Jobs/JobDrawer.js'
31
- import { NewBatchJob } from './Jobs/placeholders.js'
35
+ import { JobDrawer, type DrawerNotice } from './Jobs/JobDrawer.js'
36
+ import { NewBatchJob } from './Jobs/NewBatchJob.js'
32
37
  import { usePageVisible, useJobsFeed } from './Jobs/useJobsFeed.js'
33
38
  import { WorkerStrip } from './Jobs/WorkerStrip.js'
34
39
 
35
40
  export interface JobsProps {
36
41
  onNavigate: (path: string) => void
42
+ /**
43
+ * The signed-in user's role. It decides which job actions show: viewers
44
+ * get none (the server refuses them anyway). Without one, none show.
45
+ */
46
+ role?: string | null
47
+ /** The signed-in user's id: a batch's Retry shows to the person who queued it. */
48
+ userId?: string | null
49
+ /** The CMS config: the batch wizard lists its page and post collections. */
50
+ config?: unknown
37
51
  }
38
52
 
39
53
  const FILTER_OPTIONS = JOB_FILTERS.map(({ id, label }) => ({ id, label }))
40
54
 
55
+ /** What a run, retry or cancel came to, for a toast and the drawer's live region. */
56
+ interface Outcome {
57
+ text: string
58
+ tone: 'info' | 'error'
59
+ }
60
+
41
61
  /** `.jb-livebtn`: pauses and resumes the 5-second refresh. */
42
62
  function LiveButton({ live, onToggle }: { live: boolean; onToggle: () => void }) {
43
63
  return (
@@ -65,10 +85,10 @@ function LiveButton({ live, onToggle }: { live: boolean; onToggle: () => void })
65
85
  * seconds while Live is on and the tab is visible. Filters and counts work
66
86
  * on the loaded rows.
67
87
  *
68
- * Opening a job sets `open` and shows its drawer (P-18). The batch wizard
69
- * (P-20) renders from `./Jobs/placeholders.js` until it's built.
88
+ * Opening a job sets `open` and shows its drawer (P-18). New batch AI job
89
+ * opens the wizard (P-20); a queued job is added at the top of the list.
70
90
  */
71
- export function Jobs({ onNavigate }: JobsProps) {
91
+ export function Jobs({ onNavigate, role = null, userId = null, config }: JobsProps) {
72
92
  const [live, setLive] = useState(true)
73
93
  const visible = usePageVisible()
74
94
  const feed = useJobsFeed(live && visible)
@@ -80,13 +100,26 @@ export function Jobs({ onNavigate }: JobsProps) {
80
100
  const cancelledId = useRef<string | null>(null)
81
101
  const [wizard, setWizard] = useState(false)
82
102
  const [retryingId, setRetryingId] = useState<string | null>(null)
103
+ /** Jobs with a retry request out, so a second click can't post another. */
104
+ const retrying = useRef(new Set<string>())
83
105
  /** A row whose action button just went away; its open button takes focus if focus was lost. */
84
106
  const refocus = useRef<string | null>(null)
107
+ /**
108
+ * The latest outcome, which the open drawer announces too: screen readers
109
+ * are kept inside the drawer (aria-modal), away from the toaster. Cleared
110
+ * whenever a drawer opens or closes, so a drawer never announces an older one.
111
+ */
112
+ const [notice, setNotice] = useState<DrawerNotice | null>(null)
113
+ /** A cancel's outcome, held until its dialog has gone (`onClosed`). */
114
+ const cancelOutcome = useRef<Outcome | null>(null)
115
+ const viewer = useMemo<JobViewer>(() => ({ role, userId }), [role, userId])
85
116
 
86
117
  useEffect(() => {
87
118
  const id = refocus.current
88
119
  if (!id) return
89
120
  refocus.current = null
121
+ // The drawer places focus after its own footer's actions.
122
+ if (open) return
90
123
  const current = document.activeElement
91
124
  if (current && current !== document.body && current.isConnected) return
92
125
  focusJobRow(id)
@@ -112,56 +145,97 @@ export function Jobs({ onNavigate }: JobsProps) {
112
145
  const openRow = open ? rows.find((row) => row.id === open.id) : undefined
113
146
  const ready = feed.status === 'ready'
114
147
 
115
- const openAt = (id: string, tab: JobDrawerTab = null) => setOpen({ id, tab })
148
+ const openAt = (id: string, tab: JobDrawerTab = null) => {
149
+ setOpen({ id, tab })
150
+ setNotice(null)
151
+ }
152
+
153
+ /** Toast an outcome, and have an open drawer announce it. */
154
+ function report(text: string, tone: Outcome['tone'] = 'info') {
155
+ if (tone === 'error') toast.error(text)
156
+ else toast(text)
157
+ setNotice((prev) => ({ text, n: (prev?.n ?? 0) + 1 }))
158
+ }
116
159
 
117
160
  async function runNow(t: RunnableJobType) {
118
161
  const res = await runJobNow(t)
119
162
  if (!res.ok) {
120
- toast.error(`${JOB_TYPES[t].label} didn’t start: ${res.error}`)
163
+ report(`${JOB_TYPES[t].label} didn’t start: ${res.error}`, 'error')
121
164
  return
122
165
  }
123
166
  const { job, reused } = res.value
124
167
  upsert(job)
125
168
  if (reused) {
126
- toast(`${JOB_TYPES[t].label} is already ${job.status}. Reusing that job.`)
127
169
  openAt(job.id)
170
+ report(`${JOB_TYPES[t].label} is already ${job.status}. Reusing that job.`)
128
171
  } else {
129
- toast(`${JOB_TYPES[t].label} queued. It starts on the next worker run.`)
172
+ report(`${JOB_TYPES[t].label} queued. It starts on the next worker run.`)
130
173
  }
131
174
  void reload()
132
175
  }
133
176
 
134
- async function retry(j: JobRow) {
177
+ /**
178
+ * Retry a failed job from where it stopped, or ("Retry now") one waiting
179
+ * out a failed slice's backoff. A 409 means it was retried elsewhere or
180
+ * isn't waiting any more, so the list is read again before the answer shows.
181
+ */
182
+ async function retry(j: JobRow, now = false) {
183
+ if (retrying.current.has(j.id)) return
184
+ retrying.current.add(j.id)
135
185
  setRetryingId(j.id)
136
- const res = await retryJob(j.id)
137
- setRetryingId(null)
138
- if (!res.ok) {
139
- toast.error(`“${j.title}” wasn’t retried: ${res.error}`)
140
- return
186
+ try {
187
+ const res = await retryJob(j.id)
188
+ if (res.ok) {
189
+ refocus.current = j.id
190
+ upsert(res.value)
191
+ report(now ? 'Retrying on the next worker run' : `Retrying from ${retryPoint(j)}`)
192
+ void reload()
193
+ return
194
+ }
195
+ const message = `“${j.title}” wasn’t retried: ${res.error}`
196
+ if (res.status === 409) await reload()
197
+ report(message, res.status === 409 ? 'info' : 'error')
198
+ } finally {
199
+ retrying.current.delete(j.id)
200
+ setRetryingId(null)
141
201
  }
142
- refocus.current = j.id
143
- upsert(res.value)
144
- toast(`Retrying from ${jobType(j.type).unit === 'pages' ? 'page' : 'document'} ${j.done + 1}`)
145
- void reload()
146
202
  }
147
203
 
204
+ function askCancel(row: JobRow) {
205
+ cancelledId.current = row.id
206
+ setCancelTarget(row)
207
+ }
208
+
209
+ /**
210
+ * The dialog's Cancel job. A cancel that lands after the job ended comes
211
+ * back as the job, unchanged. A 409 (it ended, or was cancelled, first) is
212
+ * shown once the list has been read again, so the dialog closes onto it.
213
+ */
148
214
  async function confirmCancel(j: JobRow) {
149
215
  const res = await cancelJob(j.id)
150
216
  if (!res.ok) {
151
- toast.error(`“${j.title}” wasn’t cancelled: ${res.error}`)
217
+ const text = `“${j.title}” wasn’t cancelled: ${res.error}`
218
+ if (res.status === 409) await reload()
219
+ cancelOutcome.current = { text, tone: res.status === 409 ? 'info' : 'error' }
152
220
  return
153
221
  }
154
222
  const job = res.value
155
223
  upsert(job)
156
- if (job.status === 'cancelled') toast('Job cancelled')
157
- else if (staleRunning(j)) toast('Stopping on the next worker run')
158
- else if (job.status === 'queued' || job.status === 'running') toast('Stopping after this slice')
159
- else toast(`“${j.title}” had already finished.`)
224
+ let text: string
225
+ if (job.status === 'cancelled') text = 'Job cancelled'
226
+ else if (staleRunning(j)) text = 'Stopping on the next worker run'
227
+ else if (job.status === 'queued' || job.status === 'running') text = 'Stopping after this slice'
228
+ else text = `“${j.title}” had already finished.`
229
+ cancelOutcome.current = { text, tone: 'info' }
160
230
  void reload()
161
231
  }
162
232
 
163
233
  const deadFirst = dead[0]
164
234
  const firstReview = revJobs[0]
235
+ // The dialog follows the job as the list refreshes, so its copy fits the state it's in.
236
+ const cancelRow = cancelTarget
237
+ ? (rows.find((row) => row.id === cancelTarget.id) ?? cancelTarget)
238
+ : null
165
239
  const deadCause = deadFirst?.error?.replace(/\.\s*$/, '')
166
240
 
167
241
  return (
@@ -173,13 +247,16 @@ export function Jobs({ onNavigate }: JobsProps) {
173
247
  actions={
174
248
  <>
175
249
  <LiveButton live={live} onToggle={() => setLive((value) => !value)} />
176
- <Button
177
- data-jobs-new=""
178
- leftIcon={<JobIcons.spark size={14} />}
179
- onClick={() => setWizard(true)}
180
- >
181
- New batch AI job
182
- </Button>
250
+ {/* Like Run now: the server refuses a batch to anyone else. */}
251
+ {canActOnJobs(viewer) ? (
252
+ <Button
253
+ data-jobs-new=""
254
+ leftIcon={<JobIcons.spark size={14} />}
255
+ onClick={() => setWizard(true)}
256
+ >
257
+ New batch AI job
258
+ </Button>
259
+ ) : null}
183
260
  </>
184
261
  }
185
262
  />
@@ -191,6 +268,7 @@ export function Jobs({ onNavigate }: JobsProps) {
191
268
  loading={feed.status === 'loading'}
192
269
  rows={rows}
193
270
  ticking={live && visible}
271
+ canRun={canActOnJobs(viewer)}
194
272
  onRunNow={runNow}
195
273
  />
196
274
 
@@ -211,14 +289,16 @@ export function Jobs({ onNavigate }: JobsProps) {
211
289
  <Button variant="secondary" size="sm" onClick={() => openAt(deadFirst.id, 'overview')}>
212
290
  See why
213
291
  </Button>
214
- <BusyButton
215
- size="sm"
216
- leftIcon={<JobIcons.retry size={12} />}
217
- busy={retryingId === deadFirst.id}
218
- onClick={() => void retry(deadFirst)}
219
- >
220
- Retry
221
- </BusyButton>
292
+ {canRetryJob(deadFirst, viewer) ? (
293
+ <BusyButton
294
+ size="sm"
295
+ leftIcon={<JobIcons.retry size={12} />}
296
+ busy={retryingId === deadFirst.id}
297
+ onClick={() => void retry(deadFirst)}
298
+ >
299
+ Retry
300
+ </BusyButton>
301
+ ) : null}
222
302
  </div>
223
303
  ) : null}
224
304
 
@@ -278,11 +358,9 @@ export function Jobs({ onNavigate }: JobsProps) {
278
358
  status={feed.status}
279
359
  error={feed.error}
280
360
  retryingId={retryingId}
361
+ viewer={viewer}
281
362
  onOpen={openAt}
282
- onCancel={(row) => {
283
- cancelledId.current = row.id
284
- setCancelTarget(row)
285
- }}
363
+ onCancel={askCancel}
286
364
  onRetry={(row) => void retry(row)}
287
365
  onReload={reload}
288
366
  />
@@ -295,18 +373,54 @@ export function Jobs({ onNavigate }: JobsProps) {
295
373
  tab={open.tab}
296
374
  now={feed.fetchedAt}
297
375
  worker={feed.worker}
298
- onClose={() => setOpen(null)}
376
+ actions={jobActions(openRow, viewer)}
377
+ retrying={retryingId === openRow.id}
378
+ notice={notice}
379
+ onCancel={() => askCancel(openRow)}
380
+ onRetry={(now) => retry(openRow, now)}
381
+ onClose={() => {
382
+ setOpen(null)
383
+ setNotice(null)
384
+ }}
299
385
  onReload={() => void reload()}
300
386
  onNavigate={onNavigate}
301
387
  />
302
388
  ) : null}
303
- {wizard ? <NewBatchJob onClose={() => setWizard(false)} /> : null}
389
+ {wizard ? (
390
+ <NewBatchJob
391
+ role={role}
392
+ config={config}
393
+ onClose={() => setWizard(false)}
394
+ onQueued={(job) => {
395
+ upsert(job)
396
+ setWizard(false)
397
+ setFilter('all')
398
+ setType('all')
399
+ toast(`“${job.title}” queued · ${jobTotal(job)} documents`)
400
+ void reload()
401
+ }}
402
+ onFocusFallback={(job) =>
403
+ job
404
+ ? focusJobRow(job.id)
405
+ : document.querySelector<HTMLElement>('[data-jobs-new]')?.focus()
406
+ }
407
+ />
408
+ ) : null}
304
409
  <CancelJobDialog
305
- job={cancelTarget}
410
+ job={cancelRow}
306
411
  onClose={() => setCancelTarget(null)}
307
412
  onConfirm={confirmCancel}
308
413
  onFocusFallback={() => {
309
- if (cancelledId.current) focusJobRow(cancelledId.current)
414
+ // Opened from the drawer, whose Cancel job went with the cancel.
415
+ if (open) focusJobDrawer()
416
+ else if (cancelledId.current) focusJobRow(cancelledId.current)
417
+ }}
418
+ onClosed={() => {
419
+ // Screen readers are kept inside the dialog while it's open, so a
420
+ // toast or the drawer's announcement then could go unread.
421
+ const outcome = cancelOutcome.current
422
+ cancelOutcome.current = null
423
+ if (outcome) report(outcome.text, outcome.tone)
310
424
  }}
311
425
  />
312
426
  </div>
@@ -0,0 +1,231 @@
1
+ 'use client'
2
+
3
+ import { useId, type ReactNode } from 'react'
4
+ import type { AnnouncementTone } from '@prenta/core'
5
+
6
+ import { Toggle } from '../../components/ui/Toggle.js'
7
+ import {
8
+ ANNOUNCEMENT_LINK_MAX,
9
+ ANNOUNCEMENT_MAX,
10
+ ANNOUNCEMENT_TONES,
11
+ type AnnouncementForm,
12
+ } from './chrome-form.js'
13
+ import { CARD, FieldLabel, Help, INPUT, MONO, RadioPills } from './controls.js'
14
+
15
+ /** The swatch beside each Style pill. The site styles the tones; this only hints. */
16
+ const TONE_SWATCH: Record<AnnouncementTone, string> = {
17
+ brand: 'var(--acc)',
18
+ dark: 'var(--site-txt)',
19
+ alert: 'var(--site-alert)',
20
+ }
21
+
22
+ /** A labelled switch row (the kit's `Line`): label, a help line, then the switch. */
23
+ function SwitchLine({
24
+ label,
25
+ sub,
26
+ on,
27
+ onChange,
28
+ disabled,
29
+ }: {
30
+ label: string
31
+ sub: string
32
+ on: boolean
33
+ onChange: (on: boolean) => void
34
+ disabled: boolean
35
+ }) {
36
+ const id = useId()
37
+ return (
38
+ <div className="text-sm2 text-txt flex items-center gap-2 leading-[normal] font-medium">
39
+ <span className="min-w-0 flex-1">
40
+ <span id={`${id}-label`}>{label}</span>
41
+ <small id={`${id}-sub`} className="text-hint text-faint mt-px block font-normal">
42
+ {sub}
43
+ </small>
44
+ </span>
45
+ <Toggle
46
+ aria-labelledby={`${id}-label`}
47
+ aria-describedby={`${id}-sub`}
48
+ checked={on}
49
+ disabled={disabled}
50
+ onCheckedChange={onChange}
51
+ />
52
+ </div>
53
+ )
54
+ }
55
+
56
+ function Field({
57
+ label,
58
+ htmlFor,
59
+ help,
60
+ children,
61
+ }: {
62
+ label: string
63
+ htmlFor: string
64
+ help?: ReactNode
65
+ children: ReactNode
66
+ }) {
67
+ return (
68
+ <div className="min-w-0">
69
+ <FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
70
+ {children}
71
+ {help ? <Help>{help}</Help> : null}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export interface AnnouncementTabProps {
77
+ form: AnnouncementForm
78
+ onChange: (form: AnnouncementForm) => void
79
+ problem: string | null
80
+ readOnly: boolean
81
+ }
82
+
83
+ /**
84
+ * The announcement bar card. The header switch turns the bar on and off; while
85
+ * it's off the body is at 50% and can't be used. The site styles the tones and
86
+ * keeps a visitor's dismissal by the announcement's `id`, which the server
87
+ * changes whenever the message, link or tone changes.
88
+ */
89
+ export function AnnouncementTab({ form, onChange, problem, readOnly }: AnnouncementTabProps) {
90
+ const id = useId()
91
+ const set = (patch: Partial<AnnouncementForm>) => onChange({ ...form, ...patch })
92
+ const off = !form.on
93
+ const bodyDisabled = readOnly || off
94
+
95
+ return (
96
+ <section aria-labelledby={`${id}-title`} className={CARD}>
97
+ <div className="border-bdr flex items-center gap-2.5 border-b px-4 py-[13px]">
98
+ <div className="min-w-0 flex-1">
99
+ <h2
100
+ id={`${id}-title`}
101
+ className="text-card-title text-txt m-0 leading-[normal] font-[650]"
102
+ >
103
+ Announcement bar
104
+ </h2>
105
+ <p className="text-meta text-sub m-0 mt-px leading-[normal]">
106
+ Shows above the header on every page. No developer needed.
107
+ </p>
108
+ </div>
109
+ <Toggle
110
+ aria-label="Show announcement bar"
111
+ checked={form.on}
112
+ disabled={readOnly}
113
+ onCheckedChange={(on) => set({ on })}
114
+ />
115
+ </div>
116
+ <div
117
+ data-off={off || undefined}
118
+ // Off: 50% and not interactive (the kit's `pointer-events: none`);
119
+ // `inert` also keeps it out of the Tab order and the accessibility tree.
120
+ inert={off || undefined}
121
+ className={`flex flex-col gap-3.5 px-4 py-3.5 ${off ? 'pointer-events-none opacity-50' : ''}`}
122
+ >
123
+ <Field
124
+ label="Message"
125
+ htmlFor={`${id}-text`}
126
+ help={
127
+ <span aria-live="polite">
128
+ <span className={form.text.length > ANNOUNCEMENT_MAX ? 'text-err' : undefined}>
129
+ {form.text.length}/{ANNOUNCEMENT_MAX}
130
+ </span>{' '}
131
+ · keep it to one line on mobile.
132
+ </span>
133
+ }
134
+ >
135
+ <input
136
+ id={`${id}-text`}
137
+ className={INPUT}
138
+ maxLength={ANNOUNCEMENT_MAX}
139
+ value={form.text}
140
+ disabled={bodyDisabled}
141
+ onChange={(event) => set({ text: event.target.value })}
142
+ />
143
+ </Field>
144
+ <div className="grid grid-cols-2 gap-2 [@media(width<=560px)]:grid-cols-1">
145
+ <Field label="Link text" htmlFor={`${id}-link`} help="Optional">
146
+ <input
147
+ id={`${id}-link`}
148
+ className={INPUT}
149
+ maxLength={ANNOUNCEMENT_LINK_MAX}
150
+ value={form.linkLabel}
151
+ disabled={bodyDisabled}
152
+ onChange={(event) => set({ linkLabel: event.target.value })}
153
+ />
154
+ </Field>
155
+ <Field label="Links to" htmlFor={`${id}-url`}>
156
+ <input
157
+ id={`${id}-url`}
158
+ className={`${INPUT} ${MONO}`}
159
+ value={form.linkUrl}
160
+ spellCheck={false}
161
+ disabled={bodyDisabled}
162
+ onChange={(event) => set({ linkUrl: event.target.value })}
163
+ />
164
+ </Field>
165
+ </div>
166
+ <div>
167
+ <span className="text-meta text-txt mb-[5px] block font-semibold">Style</span>
168
+ <RadioPills
169
+ name={`${id}-tone`}
170
+ label="Style"
171
+ value={form.tone}
172
+ disabled={bodyDisabled}
173
+ options={ANNOUNCEMENT_TONES.map((tone) => ({
174
+ id: tone.id,
175
+ label: tone.label,
176
+ swatch: TONE_SWATCH[tone.id],
177
+ }))}
178
+ onChange={(tone) => set({ tone: tone as AnnouncementTone })}
179
+ />
180
+ <Help>Your site styles each tone with its own colors.</Help>
181
+ </div>
182
+ <SwitchLine
183
+ label="Visitors can dismiss it"
184
+ sub="Stays hidden for that visitor until the message changes."
185
+ on={form.dismissible}
186
+ disabled={bodyDisabled}
187
+ onChange={(dismissible) => set({ dismissible })}
188
+ />
189
+ <SwitchLine
190
+ label="Schedule"
191
+ sub="Turns itself on and off. Your site is refreshed automatically."
192
+ on={form.scheduled}
193
+ disabled={bodyDisabled}
194
+ onChange={(scheduled) => set({ scheduled })}
195
+ />
196
+ {form.scheduled ? (
197
+ <div className="grid grid-cols-2 gap-2 [@media(width<=560px)]:grid-cols-1">
198
+ <Field label="Starts" htmlFor={`${id}-start`}>
199
+ <input
200
+ id={`${id}-start`}
201
+ className={INPUT}
202
+ type="datetime-local"
203
+ value={form.startsAt}
204
+ disabled={bodyDisabled}
205
+ onChange={(event) => set({ startsAt: event.target.value })}
206
+ />
207
+ </Field>
208
+ <Field label="Ends" htmlFor={`${id}-end`}>
209
+ <input
210
+ id={`${id}-end`}
211
+ className={INPUT}
212
+ type="datetime-local"
213
+ value={form.endsAt}
214
+ disabled={bodyDisabled}
215
+ onChange={(event) => set({ endsAt: event.target.value })}
216
+ />
217
+ </Field>
218
+ </div>
219
+ ) : null}
220
+ </div>
221
+ {problem ? (
222
+ // Outside the body, so it shows at full strength while the bar is off.
223
+ <div className="-mt-1.5 px-4 pb-3.5">
224
+ <Help tone="error">
225
+ <span role="alert">{problem}</span>
226
+ </Help>
227
+ </div>
228
+ ) : null}
229
+ </section>
230
+ )
231
+ }