@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
package/src/lib/api.ts CHANGED
@@ -82,6 +82,8 @@ export interface CmsApiResult<T> {
82
82
  * narrow it themselves.
83
83
  */
84
84
  body?: unknown
85
+ /** The response's `ETag`, for endpoints that take `If-Match` on the next write. */
86
+ etag?: string
85
87
  status: number
86
88
  }
87
89
 
@@ -118,6 +120,10 @@ export async function cmsApi<T = unknown>(
118
120
  const res = await fetch(url, { ...options, headers, credentials: 'include' })
119
121
  const json = await res.json().catch(() => ({}))
120
122
 
123
+ // Test doubles often stub `fetch` with a bare `{ ok, json }`.
124
+ const etag =
125
+ typeof res.headers?.get === 'function' ? (res.headers.get('ETag') ?? undefined) : undefined
126
+
121
127
  if (!res.ok) {
122
128
  return {
123
129
  error: json.error || `Request failed (${res.status})`,
@@ -127,7 +133,12 @@ export async function cmsApi<T = unknown>(
127
133
  }
128
134
  }
129
135
 
130
- return { data: json.data ?? json, body: json, status: res.status }
136
+ return {
137
+ data: json.data ?? json,
138
+ body: json,
139
+ status: res.status,
140
+ ...(etag ? { etag } : {}),
141
+ }
131
142
  } catch (err) {
132
143
  return { error: err instanceof Error ? err.message : 'Network error', status: 0 }
133
144
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The New batch AI job wizard (jobs-new.jsx, TRACKING P-20): its sources,
3
+ * validation and request, kept apart from the component so they can be
4
+ * tested on their own. The server's rules are in cms-core `jobs/batch-ai.ts`
5
+ * and CONFLICTS.md §4 (P-16).
6
+ */
7
+ import type {
8
+ BatchAiEstimate,
9
+ BatchAiPayload,
10
+ BatchAiRequest,
11
+ BatchAiTextField,
12
+ } from '@prenta/core'
13
+ import { JOB_OPS } from './job-view.js'
14
+
15
+ export type BatchOperation = BatchAiPayload['operation']
16
+ export type BatchOp = (typeof JOB_OPS)[number]
17
+
18
+ /** Where a batch runs: a page or post collection, or the media library. */
19
+ export interface BatchSource {
20
+ /** The collection's key in the config, or `media`. */
21
+ id: string
22
+ /** The kit's Content select label ("Posts", "Media library"). */
23
+ label: string
24
+ }
25
+
26
+ export const MEDIA_SOURCE: BatchSource = { id: 'media', label: 'Media library' }
27
+
28
+ /**
29
+ * Documents picked in a list (P-21's "Run with AI…"). They run as they are,
30
+ * without the operation's "Only where" preset.
31
+ */
32
+ export interface BatchSelection {
33
+ /** A collection key, or `media` for images picked in the media library. */
34
+ source: string
35
+ documentIds: string[]
36
+ }
37
+
38
+ /** A custom instruction needs more than 8 characters, and at most 2,000. */
39
+ const INSTRUCTION_MIN = 9
40
+ export const INSTRUCTION_MAX = 2000
41
+ /** The most documents one batch freezes. */
42
+ const BATCH_MAX = 1000
43
+
44
+ /**
45
+ * The fields a custom instruction can rewrite. `field` isn't in the kit's
46
+ * contract; it's the kit's "Field you choose" (CONFLICTS.md §4, P-16).
47
+ */
48
+ export const CUSTOM_FIELDS: ReadonlyArray<{ id: BatchAiTextField; label: string }> = [
49
+ { id: 'metaDescription', label: 'Meta description' },
50
+ { id: 'seoTitle', label: 'SEO title' },
51
+ { id: 'excerpt', label: 'Excerpt' },
52
+ ]
53
+
54
+ export function batchOp(id: BatchOperation): BatchOp {
55
+ return JOB_OPS.find((op) => op.id === id) ?? JOB_OPS[0]
56
+ }
57
+
58
+ interface ConfigCollection {
59
+ slug?: string
60
+ type?: string
61
+ labels?: { plural?: string }
62
+ admin?: { hidden?: boolean }
63
+ }
64
+
65
+ /**
66
+ * The Content select's options: the config's page and post collections, in
67
+ * config order, without hidden ones (as Settings → AI's brand voice lists
68
+ * them). The media library is added for alt text only.
69
+ */
70
+ export function batchSources(config: unknown): BatchSource[] {
71
+ const raw = (config as { collections?: unknown } | null | undefined)?.collections
72
+ if (!raw || typeof raw !== 'object') return []
73
+ const entries: Array<[string, ConfigCollection]> = Array.isArray(raw)
74
+ ? (raw as ConfigCollection[]).map((c) => [c.slug ?? '', c])
75
+ : Object.entries(raw as Record<string, ConfigCollection>)
76
+ return entries
77
+ .filter(([key, c]) => key && (c.type === 'page' || c.type === 'post') && !c.admin?.hidden)
78
+ .map(([key, c]) => ({
79
+ id: key,
80
+ label: c.labels?.plural ?? key.charAt(0).toUpperCase() + key.slice(1),
81
+ }))
82
+ }
83
+
84
+ /** cms-core's `ADMIN_ROLES`: who may queue a batch that applies changes automatically. */
85
+ export function isAdminRole(role: string | null | undefined): boolean {
86
+ return role === 'OWNER' || role === 'ADMIN'
87
+ }
88
+
89
+ /** Alt text runs on images; every other operation on a collection. */
90
+ export function runsOnMedia(operation: BatchOperation): boolean {
91
+ return operation === 'alt'
92
+ }
93
+
94
+ /** Whether an operation can run on a picked selection's source. */
95
+ export function operationFits(
96
+ operation: BatchOperation,
97
+ selection: BatchSelection | null,
98
+ ): boolean {
99
+ if (!selection) return true
100
+ return runsOnMedia(operation) === (selection.source === MEDIA_SOURCE.id)
101
+ }
102
+
103
+ /** Everything the wizard asks, before it becomes a request. */
104
+ export interface BatchDraft {
105
+ operation: BatchOperation
106
+ instruction: string
107
+ field: BatchAiTextField
108
+ /** The collection picked in the Content select (ignored for alt text). */
109
+ collection: string
110
+ statusFilter: 'published' | 'all'
111
+ review: 'review' | 'auto'
112
+ onApprove: 'draft' | 'publish'
113
+ selection: BatchSelection | null
114
+ }
115
+
116
+ /** The source a draft runs on: the media library for alt text, else its collection. */
117
+ function draftSource(draft: Pick<BatchDraft, 'operation' | 'collection'>): string {
118
+ return runsOnMedia(draft.operation) ? MEDIA_SOURCE.id : draft.collection
119
+ }
120
+
121
+ /** Step 1's check: a custom instruction needs more than 8 characters (the server's message). */
122
+ export function instructionError(operation: BatchOperation, instruction: string): string | null {
123
+ if (operation !== 'custom') return null
124
+ return instruction.trim().length < INSTRUCTION_MIN
125
+ ? 'A custom instruction needs more than 8 characters.'
126
+ : null
127
+ }
128
+
129
+ /**
130
+ * The request for estimate, preview and create. "Apply automatically" never
131
+ * publishes (the server refuses it), so it always sends `draft`.
132
+ */
133
+ export function batchRequest(draft: BatchDraft): BatchAiRequest {
134
+ const instruction = draft.instruction.trim()
135
+ return {
136
+ operation: draft.operation,
137
+ source: draft.selection?.source ?? draftSource(draft),
138
+ statusFilter: draft.statusFilter,
139
+ review: draft.review,
140
+ onApprove: draft.review === 'auto' ? 'draft' : draft.onApprove,
141
+ instruction: instruction || null,
142
+ ...(draft.operation === 'custom' ? { field: draft.field } : {}),
143
+ ...(draft.selection ? { filter: { documentIds: draft.selection.documentIds } } : {}),
144
+ }
145
+ }
146
+
147
+ /**
148
+ * The fields that change which documents match, or what the AI writes. An
149
+ * estimate or preview is stale once its key differs from the draft's.
150
+ */
151
+ export function estimateKey(request: BatchAiRequest): string {
152
+ return JSON.stringify([
153
+ request.operation,
154
+ request.source,
155
+ request.statusFilter,
156
+ request.filter?.documentIds ?? null,
157
+ ])
158
+ }
159
+
160
+ export function previewKey(request: BatchAiRequest): string {
161
+ return JSON.stringify([estimateKey(request), request.instruction, request.field ?? null])
162
+ }
163
+
164
+ /** Why step 2 can't continue: nothing matches, or more than a batch holds. Null when it can. */
165
+ export function matchProblem(estimate: BatchAiEstimate): string | null {
166
+ if (estimate.count === 0) return 'No documents match this filter.'
167
+ if (estimate.count > BATCH_MAX) {
168
+ return `This matches ${estimate.count.toLocaleString('en-US')} documents. Narrow it to ${BATCH_MAX.toLocaleString('en-US')} or fewer.`
169
+ }
170
+ return null
171
+ }
172
+
173
+ /** The estimate tiles: "~42 min" and "~38k tokens", as the kit rounds them. */
174
+ export function estimateTiles(estimate: BatchAiEstimate): { minutes: string; tokens: string } {
175
+ return {
176
+ minutes: `~${estimate.minutes.toLocaleString('en-US')} min`,
177
+ tokens: `~${Math.round(estimate.tokens / 1000).toLocaleString('en-US')}k tokens`,
178
+ }
179
+ }
180
+
181
+ /** The line under the tiles. The limit sentence is left out when Settings → AI sets none. */
182
+ export function estimateNote(estimate: BatchAiEstimate): string {
183
+ const pace = 'One document per slice, about one a minute.'
184
+ const pct = estimate.pctOfMonthlyLimit
185
+ if (pct === null) return pace
186
+ const share = pct === 0 && estimate.tokens > 0 ? 'under 1' : String(pct)
187
+ return `${pace} That’s ${share}% of this month’s AI limit.`
188
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Site chrome API (DATA_CONTRACTS §2) for Appearance → Header & Footer.
3
+ * Region values and the announcement come with an `ETag`; a save sends it as
4
+ * `If-Match`, so a stale form gets 412 instead of overwriting a newer save.
5
+ */
6
+ import type {
7
+ Announcement,
8
+ ChromeOverridesList,
9
+ ChromeRegionName,
10
+ ChromeSettings,
11
+ ChromeSyncStatus,
12
+ RegionSchema,
13
+ RegionValues,
14
+ } from '@prenta/core'
15
+
16
+ import { cmsApi } from './api.js'
17
+
18
+ export interface ChromeSchemaView {
19
+ regions: RegionSchema[]
20
+ sync: ChromeSyncStatus
21
+ }
22
+
23
+ /** A result the screen can show: the value, or why there isn't one. */
24
+ export type ChromeResult<T> =
25
+ | { ok: true; value: T; etag: string | null }
26
+ | { ok: false; status: number; error: string }
27
+
28
+ function result<T>(res: {
29
+ data?: T
30
+ error?: string
31
+ status: number
32
+ etag?: string
33
+ }): ChromeResult<T> {
34
+ if (res.error !== undefined || res.data === undefined) {
35
+ return { ok: false, status: res.status, error: res.error ?? 'Request failed' }
36
+ }
37
+ return { ok: true, value: res.data, etag: res.etag ?? null }
38
+ }
39
+
40
+ function ifMatch(etag: string | null): Record<string, string> {
41
+ return etag ? { 'If-Match': etag } : {}
42
+ }
43
+
44
+ export async function fetchChromeSchema(): Promise<ChromeResult<ChromeSchemaView>> {
45
+ return result(await cmsApi<ChromeSchemaView>('/chrome/schema'))
46
+ }
47
+
48
+ /** 404 until the site has pushed the region's schema. */
49
+ export async function fetchRegion(region: ChromeRegionName): Promise<ChromeResult<RegionValues>> {
50
+ return result(await cmsApi<RegionValues>(`/chrome/${region}`))
51
+ }
52
+
53
+ /** What `PUT /chrome/:region` takes. `fields` replaces every saved value. */
54
+ export interface RegionSaveBody {
55
+ variant: string
56
+ fields: Record<string, unknown>
57
+ }
58
+
59
+ export async function saveRegion(
60
+ region: ChromeRegionName,
61
+ body: RegionSaveBody,
62
+ etag: string | null,
63
+ ): Promise<ChromeResult<RegionValues>> {
64
+ return result(
65
+ await cmsApi<RegionValues>(`/chrome/${region}`, {
66
+ method: 'PUT',
67
+ headers: ifMatch(etag),
68
+ body: JSON.stringify(body),
69
+ }),
70
+ )
71
+ }
72
+
73
+ /** "Remove now" on the orphan row. */
74
+ export async function removeOrphan(
75
+ region: ChromeRegionName,
76
+ key: string,
77
+ ): Promise<ChromeResult<RegionValues>> {
78
+ return result(
79
+ await cmsApi<RegionValues>(`/chrome/${region}/orphans/${encodeURIComponent(key)}`, {
80
+ method: 'DELETE',
81
+ }),
82
+ )
83
+ }
84
+
85
+ export async function fetchAnnouncement(): Promise<ChromeResult<Announcement>> {
86
+ return result(await cmsApi<Announcement>('/chrome/announcement'))
87
+ }
88
+
89
+ /** What `PUT /chrome/announcement` takes. The server mints `id`. */
90
+ export type AnnouncementSaveBody = Omit<Announcement, 'id'>
91
+
92
+ export async function saveAnnouncement(
93
+ body: AnnouncementSaveBody,
94
+ etag: string | null,
95
+ ): Promise<ChromeResult<Announcement>> {
96
+ return result(
97
+ await cmsApi<Announcement>('/chrome/announcement', {
98
+ method: 'PUT',
99
+ headers: ifMatch(etag),
100
+ body: JSON.stringify(body),
101
+ }),
102
+ )
103
+ }
104
+
105
+ export async function fetchChromeSettings(): Promise<ChromeResult<ChromeSettings>> {
106
+ return result(await cmsApi<ChromeSettings>('/chrome/settings'))
107
+ }
108
+
109
+ /**
110
+ * Saves the keys sent; one left out stays as it is. `secret` comes back once,
111
+ * when the save creates the revalidate webhook.
112
+ */
113
+ export async function saveChromeSettings(
114
+ body: Partial<ChromeSettings>,
115
+ ): Promise<ChromeResult<ChromeSettings & { secret?: string }>> {
116
+ return result(
117
+ await cmsApi<ChromeSettings & { secret?: string }>('/chrome/settings', {
118
+ method: 'PUT',
119
+ body: JSON.stringify(body),
120
+ }),
121
+ )
122
+ }
123
+
124
+ /** The pages whose `_layout` overrides a region. A core before 8.11 answers 404. */
125
+ export async function fetchChromeOverrides(): Promise<ChromeResult<ChromeOverridesList>> {
126
+ return result(await cmsApi<ChromeOverridesList>('/chrome/overrides'))
127
+ }
128
+
129
+ /** A menu item as the form and the preview mock read it. */
130
+ export interface ChromeMenuItem {
131
+ id: string
132
+ label: string
133
+ url: string
134
+ target?: '_blank' | '_self'
135
+ /** Shown as a button (the kit's "Show as button"). */
136
+ button: boolean
137
+ children: ChromeMenuItem[]
138
+ }
139
+
140
+ /** A menu a menu field can pick, by its slug. */
141
+ export interface ChromeMenuOption {
142
+ slug: string
143
+ name: string
144
+ items: ChromeMenuItem[]
145
+ }
146
+
147
+ function asText(value: unknown): string {
148
+ return typeof value === 'string' ? value : ''
149
+ }
150
+
151
+ function menuItems(value: unknown, depth = 0): ChromeMenuItem[] {
152
+ if (!Array.isArray(value) || depth > 6) return []
153
+ const out: ChromeMenuItem[] = []
154
+ value.forEach((raw, index) => {
155
+ if (!raw || typeof raw !== 'object') return
156
+ const item = raw as Record<string, unknown>
157
+ const label = asText(item.label).trim()
158
+ if (!label) return
159
+ out.push({
160
+ id: asText(item.id) || `${depth}-${index}`,
161
+ label,
162
+ url: asText(item.url),
163
+ ...(item.target === '_blank' || item.target === '_self' ? { target: item.target } : {}),
164
+ button: item.button === true,
165
+ children: menuItems(item.children, depth + 1),
166
+ })
167
+ })
168
+ return out
169
+ }
170
+
171
+ /**
172
+ * Menus from the navigation plugin's `navigations` collection. Without the
173
+ * plugin there are none, and menu fields offer only what's saved.
174
+ */
175
+ export async function fetchChromeMenus(): Promise<ChromeMenuOption[]> {
176
+ const res = await cmsApi<{
177
+ docs?: Array<{ slug?: string | null; title?: string | null; data?: unknown }>
178
+ }>('/collections/navigations?pageSize=100&sort=title&order=asc&pick=name,slug,items')
179
+ const menus: ChromeMenuOption[] = []
180
+ for (const doc of res.data?.docs ?? []) {
181
+ const data = (doc.data && typeof doc.data === 'object' ? doc.data : {}) as Record<
182
+ string,
183
+ unknown
184
+ >
185
+ const slug = asText(data.slug) || asText(doc.slug)
186
+ if (!slug || menus.some((menu) => menu.slug === slug)) continue
187
+ menus.push({
188
+ slug,
189
+ name: asText(data.name) || asText(doc.title) || slug,
190
+ items: menuItems(data.items),
191
+ })
192
+ }
193
+ return menus
194
+ }
195
+
196
+ /** The site title, for brand fields and the preview's logo. */
197
+ export async function fetchSiteTitle(): Promise<string | null> {
198
+ const res = await cmsApi<{ siteTitle?: string | null }>('/appearance')
199
+ return typeof res.data?.siteTitle === 'string' && res.data.siteTitle ? res.data.siteTitle : null
200
+ }
@@ -141,10 +141,18 @@ export interface JobRow {
141
141
  items: boolean
142
142
  /** Batch AI approval mode, for the cancel copy. Null for scans. */
143
143
  review: 'review' | 'auto' | null
144
+ /**
145
+ * Batch AI: only an admin may retry it, because "Apply automatically" was
146
+ * chosen when it was queued. A job from `/ai/batch` (a list's bulk actions)
147
+ * also applies directly, but stores no review mode, so it isn't admin-only.
148
+ */
149
+ adminRetry: boolean
144
150
  /** Run time of a finished job, e.g. "17 min". */
145
151
  took: string | null
146
152
  /** Who queued it: a person, or the cron. */
147
153
  by: string
154
+ /** The id of the person who queued it; null for a cron run. */
155
+ byId: string | null
148
156
  /** When it was queued, e.g. "34 min ago" or "Yesterday 16:04". */
149
157
  created: string
150
158
  }
@@ -261,6 +269,70 @@ export function hasReviewAction(j: JobRow): boolean {
261
269
  return view(j).k === 'review' || (j.items && j.pending > 0)
262
270
  }
263
271
 
272
+ /** "document 8" or "page 101": where a retry picks up (jobs-drawer.jsx footer, jobs-view.jsx toast). */
273
+ export function retryPoint(j: Pick<JobRow, 'type' | 'done'>): string {
274
+ return `${jobType(j.type).unit === 'pages' ? 'page' : 'document'} ${j.done + 1}`
275
+ }
276
+
277
+ /** Who is looking at the Jobs screen, from the admin session. */
278
+ export interface JobViewer {
279
+ role: string | null
280
+ userId: string | null
281
+ }
282
+
283
+ /** Roles that may run, cancel and retry jobs: cms-core `WRITE_ROLES` (`requireStaffAccess`). */
284
+ const JOB_WRITE_ROLES: ReadonlySet<string> = new Set(['OWNER', 'ADMIN', 'EDITOR', 'AUTHOR'])
285
+ /** cms-core `ADMIN_ROLES`: may retry any batch. */
286
+ const JOB_ADMIN_ROLES: ReadonlySet<string> = new Set(['OWNER', 'ADMIN'])
287
+
288
+ function roleOf(viewer: JobViewer): string {
289
+ return (viewer.role ?? '').toUpperCase()
290
+ }
291
+
292
+ /** Run now and Cancel. An unknown role fails closed; the server checks every request anyway. */
293
+ export function canActOnJobs(viewer: JobViewer): boolean {
294
+ return JOB_WRITE_ROLES.has(roleOf(viewer))
295
+ }
296
+
297
+ /**
298
+ * Retry, as cms-core's retry route allows it: a write role, and for a batch
299
+ * (`batchRetryDenial`) an admin, or the person who queued it when it doesn't
300
+ * apply changes directly. The server also wants their role unchanged since
301
+ * they queued it, which the list doesn't carry; it refuses that with a 403.
302
+ */
303
+ export function canRetryJob(
304
+ j: Pick<JobRow, 'type' | 'adminRetry' | 'byId'>,
305
+ viewer: JobViewer,
306
+ ): boolean {
307
+ if (!canActOnJobs(viewer)) return false
308
+ if (j.type !== 'batch-ai' || JOB_ADMIN_ROLES.has(roleOf(viewer))) return true
309
+ return !j.adminRetry && j.byId !== null && j.byId === viewer.userId
310
+ }
311
+
312
+ /** The job actions a row or the drawer footer (jobs-drawer.jsx `.jd-f`) offers this viewer. */
313
+ export interface JobActions {
314
+ /** Cancel job: while it's queued or running, until a cancel is asked for. */
315
+ cancel: boolean
316
+ /** "Retry from document {n+1}": a failed job. */
317
+ retry: boolean
318
+ /**
319
+ * "Retry now": a job waiting out a failed slice's backoff. Not on a stale
320
+ * lock, which the server still holds as running (the next worker run picks
321
+ * it up), and not while cancelling: a retry clears the cancel.
322
+ */
323
+ retryNow: boolean
324
+ }
325
+
326
+ export function jobActions(j: JobRow, viewer: JobViewer): JobActions {
327
+ const retryable = canRetryJob(j, viewer)
328
+ return {
329
+ cancel: canActOnJobs(viewer) && active(j) && !j.cancelling,
330
+ retry: retryable && j.state === 'dead',
331
+ retryNow:
332
+ retryable && j.state === 'queued' && j.status === 'queued' && !!j.error && !j.cancelling,
333
+ }
334
+ }
335
+
264
336
  /** A running job whose lock is older than this crashed mid-slice (JOBS.md). */
265
337
  const STALE_LOCK_MS = 10 * 60_000
266
338
  /** The worker cron runs once a minute. */
@@ -392,6 +464,11 @@ function who(job: Job): string {
392
464
  return by.name
393
465
  }
394
466
 
467
+ function whoId(job: Job): string | null {
468
+ const by = job.createdBy
469
+ return by && 'id' in by ? by.id : null
470
+ }
471
+
395
472
  /** Claim order (`claimNext`): earliest `runAt`, then oldest. */
396
473
  function claimsBefore(a: Job, b: Job): boolean {
397
474
  const runA = Date.parse(a.runAt)
@@ -400,6 +477,16 @@ function claimsBefore(a: Job, b: Job): boolean {
400
477
  return Date.parse(a.createdAt) < Date.parse(b.createdAt)
401
478
  }
402
479
 
480
+ /**
481
+ * How many units a job covers. A batch's worker sets `progress.total` on its
482
+ * first slice, so until then it's the documents frozen in its payload.
483
+ */
484
+ export function jobTotal(job: Job): number {
485
+ if (job.progress.total > 0 || job.type !== 'batch-ai') return job.progress.total
486
+ const ids = 'documentIds' in job.payload ? job.payload.documentIds : null
487
+ return Array.isArray(ids) ? ids.length : 0
488
+ }
489
+
403
490
  /**
404
491
  * API jobs in the kit's row shape. `now` is when the list was fetched, so the
405
492
  * retry countdown and "34 min ago" move with each poll. `worker` aligns the
@@ -427,7 +514,7 @@ export function toJobRows(jobs: Job[], now: number, worker: JobWorkerStatus | nu
427
514
  status: job.status,
428
515
  cancelling: !!job.cancelRequestedAt && isActive(job),
429
516
  done: job.progress.done,
430
- total: job.progress.total,
517
+ total: jobTotal(job),
431
518
  attempts: job.attempts,
432
519
  maxAttempts: job.maxAttempts,
433
520
  error,
@@ -448,8 +535,15 @@ export function toJobRows(jobs: Job[], now: number, worker: JobWorkerStatus | nu
448
535
  pending: review?.pending ?? 0,
449
536
  items: !!review && review.pending + review.approved + review.rejected > 0,
450
537
  review: job.type === 'batch-ai' && 'review' in job.payload ? job.payload.review : null,
538
+ // cms-core `batchAiWriter`: only a stored `review: 'auto'` is admin-only.
539
+ adminRetry:
540
+ job.type === 'batch-ai' &&
541
+ 'review' in job.payload &&
542
+ job.payload.review === 'auto' &&
543
+ job.payload.origin !== 'ai-batch',
451
544
  took: finished ? formatDuration(Date.parse(finished) - Date.parse(job.createdAt)) : null,
452
545
  by: who(job),
546
+ byId: whoId(job),
453
547
  created: formatCreated(job.createdAt, now),
454
548
  }
455
549
  })
@@ -7,7 +7,15 @@
7
7
  * failed request from an empty list and show the server's message (a
8
8
  * viewer's 403, a 409 for a job that can't be retried).
9
9
  */
10
- import type { Job, JobEvent, JobReviewItem, JobWorkerStatus } from '@prenta/core'
10
+ import type {
11
+ BatchAiEstimate,
12
+ BatchAiPreviewItem,
13
+ BatchAiRequest,
14
+ Job,
15
+ JobEvent,
16
+ JobReviewItem,
17
+ JobWorkerStatus,
18
+ } from '@prenta/core'
11
19
  import { cmsApi, getApiBase } from './api.js'
12
20
 
13
21
  export type JobsOutcome<T> = { ok: true; value: T } | { ok: false; error: string; status: number }
@@ -140,6 +148,53 @@ export function cancelJob(id: string): Promise<JobsOutcome<Job>> {
140
148
  return call(`/jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' })
141
149
  }
142
150
 
151
+ /**
152
+ * `GET /jobs/batch-ai/estimate`: how many documents a batch would change,
153
+ * 3 of them as a sample, and its tokens, minutes and share of the monthly AI
154
+ * limit. The query carries the request's fields, `documentIds` comma-separated.
155
+ */
156
+ export function estimateBatchAi(
157
+ request: BatchAiRequest,
158
+ signal?: AbortSignal,
159
+ ): Promise<JobsOutcome<BatchAiEstimate>> {
160
+ const params = new URLSearchParams({
161
+ operation: request.operation,
162
+ source: request.source,
163
+ statusFilter: request.statusFilter,
164
+ })
165
+ if (request.field) params.set('field', request.field)
166
+ if (request.instruction) params.set('instruction', request.instruction)
167
+ const ids = request.filter?.documentIds
168
+ if (ids?.length) params.set('documentIds', ids.join(','))
169
+ return call(`/jobs/batch-ai/estimate?${params}`, { signal })
170
+ }
171
+
172
+ /**
173
+ * `POST /jobs/batch-ai/preview`: what the AI would write for the first 3
174
+ * matching documents. Nothing is written. A document it would leave as it is
175
+ * isn't listed, and a spent AI cap is a 429.
176
+ */
177
+ export async function previewBatchAi(
178
+ request: BatchAiRequest,
179
+ signal?: AbortSignal,
180
+ ): Promise<JobsOutcome<BatchAiPreviewItem[]>> {
181
+ const res = await call<{ items?: BatchAiPreviewItem[] }>('/jobs/batch-ai/preview', {
182
+ method: 'POST',
183
+ body: JSON.stringify(request),
184
+ signal,
185
+ })
186
+ return res.ok ? { ok: true, value: res.value.items ?? [] } : res
187
+ }
188
+
189
+ /**
190
+ * `POST /jobs/batch-ai`: resolve the documents once, freeze them in a new
191
+ * queued job, and return it. "Apply automatically" is admin-only (403), and
192
+ * a filter matching no documents, or more than 1,000, is a 400.
193
+ */
194
+ export function createBatchAiJob(request: BatchAiRequest): Promise<JobsOutcome<Job>> {
195
+ return call('/jobs/batch-ai', { method: 'POST', body: JSON.stringify(request) })
196
+ }
197
+
143
198
  function jobPath(id: string): string {
144
199
  return `/jobs/${encodeURIComponent(id)}`
145
200
  }
@@ -128,6 +128,22 @@
128
128
  --code-btn-bg: #171a20;
129
129
  --code-btn-bdr: #2a2e36;
130
130
  --code-btn-txt: #c9cdd4;
131
+ --code-file: #8a8f99; /* File-name caption over a code block */
132
+
133
+ /* Site preview */
134
+ --site-bg: #ffffff;
135
+ --site-txt: #16181d;
136
+ --site-sub: #50555f;
137
+ --site-muted: #8a8f99;
138
+ --site-bdr: #e3e5ea;
139
+ --site-alt: #f4f5f7;
140
+ --site-alt2: #eef0f3;
141
+ --site-chip: #d9dce1;
142
+ --site-foot-txt: #c9cdd4;
143
+ /* The announcement's Alert tone, as the mock and the Style pills draw it */
144
+ --site-alert: #b45309;
145
+ --site-thumb: #2a2d33; /* Footer variant thumbnails */
146
+ --site-thumb-bar: #555555;
131
147
 
132
148
  /* Search preview */
133
149
  /*
@@ -155,6 +171,7 @@
155
171
  --fs-page: 21px; /* Page title */
156
172
  --fs-drawer: 16px; /* Drawer / modal title */
157
173
  --fs-stat: 20px; /* Jobs drawer result numbers */
174
+ --fs-figure: 14px; /* Batch wizard match count and estimates */
158
175
  --fs-card: 13.5px; /* Card heading */
159
176
  --fs-body: 13px; /* Body, controls, table cells */
160
177
  --fs-sm: 12.5px; /* Secondary rows, breadcrumbs, chips */
@@ -239,6 +239,19 @@
239
239
  --color-code-btn-bg: var(--code-btn-bg);
240
240
  --color-code-btn-bdr: var(--code-btn-bdr);
241
241
  --color-code-btn-txt: var(--code-btn-txt);
242
+ --color-code-file: var(--code-file);
243
+ --color-site-bg: var(--site-bg);
244
+ --color-site-txt: var(--site-txt);
245
+ --color-site-sub: var(--site-sub);
246
+ --color-site-muted: var(--site-muted);
247
+ --color-site-bdr: var(--site-bdr);
248
+ --color-site-alt: var(--site-alt);
249
+ --color-site-alt2: var(--site-alt2);
250
+ --color-site-chip: var(--site-chip);
251
+ --color-site-foot-txt: var(--site-foot-txt);
252
+ --color-site-alert: var(--site-alert);
253
+ --color-site-thumb: var(--site-thumb);
254
+ --color-site-thumb-bar: var(--site-thumb-bar);
242
255
 
243
256
  --text-page: var(--fs-page);
244
257
  --text-page--letter-spacing: -0.5px;
@@ -249,6 +262,8 @@
249
262
  --text-stat: var(--fs-stat);
250
263
  --text-stat--letter-spacing: -0.4px;
251
264
  --text-stat--font-weight: 700;
265
+ --text-figure: var(--fs-figure);
266
+ --text-figure--font-weight: 700;
252
267
  /* The kit's `text-card` would collide with the card color, which wins in v4. */
253
268
  --text-card-title: var(--fs-card);
254
269
  --text-body: var(--fs-body);