@growth-labs/cms 0.8.7 → 0.8.14

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 (56) hide show
  1. package/dist/engine/fronts-publish.d.ts.map +1 -1
  2. package/dist/engine/fronts-publish.js +44 -8
  3. package/dist/engine/fronts-publish.js.map +1 -1
  4. package/dist/engine/publisher.d.ts +3 -1
  5. package/dist/engine/publisher.d.ts.map +1 -1
  6. package/dist/engine/publisher.js +21 -11
  7. package/dist/engine/publisher.js.map +1 -1
  8. package/dist/migration-vendor.d.ts.map +1 -1
  9. package/dist/migration-vendor.js +129 -12
  10. package/dist/migration-vendor.js.map +1 -1
  11. package/dist/routes/content.d.ts +1 -0
  12. package/dist/routes/content.d.ts.map +1 -1
  13. package/dist/routes/content.js +166 -10
  14. package/dist/routes/content.js.map +1 -1
  15. package/dist/ui/api-error.d.ts +17 -0
  16. package/dist/ui/api-error.d.ts.map +1 -0
  17. package/dist/ui/api-error.js +46 -0
  18. package/dist/ui/api-error.js.map +1 -0
  19. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  20. package/dist/ui/editor/ContentForm.js +5 -4
  21. package/dist/ui/editor/ContentForm.js.map +1 -1
  22. package/dist/ui/editor/editor-media-upload.d.ts.map +1 -1
  23. package/dist/ui/editor/editor-media-upload.js +2 -1
  24. package/dist/ui/editor/editor-media-upload.js.map +1 -1
  25. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  26. package/dist/ui/inspector/PublishTab.js +2 -1
  27. package/dist/ui/inspector/PublishTab.js.map +1 -1
  28. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  29. package/dist/ui/screens/LibraryScreen.js +4 -3
  30. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  31. package/dist/ui/screens/SettingsScreen.d.ts.map +1 -1
  32. package/dist/ui/screens/SettingsScreen.js +14 -13
  33. package/dist/ui/screens/SettingsScreen.js.map +1 -1
  34. package/dist/ui/screens/TopicsScreen.d.ts.map +1 -1
  35. package/dist/ui/screens/TopicsScreen.js +3 -2
  36. package/dist/ui/screens/TopicsScreen.js.map +1 -1
  37. package/dist/ui/screens/author-recovery.d.ts.map +1 -1
  38. package/dist/ui/screens/author-recovery.js +2 -1
  39. package/dist/ui/screens/author-recovery.js.map +1 -1
  40. package/dist/ui/screens/media-upload.d.ts.map +1 -1
  41. package/dist/ui/screens/media-upload.js +2 -1
  42. package/dist/ui/screens/media-upload.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/engine/fronts-publish.ts +45 -6
  45. package/src/engine/publisher.ts +24 -11
  46. package/src/migration-vendor.ts +146 -14
  47. package/src/routes/content.ts +188 -18
  48. package/src/ui/api-error.ts +55 -0
  49. package/src/ui/editor/ContentForm.tsx +8 -13
  50. package/src/ui/editor/editor-media-upload.ts +2 -1
  51. package/src/ui/inspector/PublishTab.tsx +2 -1
  52. package/src/ui/screens/LibraryScreen.tsx +4 -3
  53. package/src/ui/screens/SettingsScreen.tsx +14 -13
  54. package/src/ui/screens/TopicsScreen.tsx +3 -2
  55. package/src/ui/screens/author-recovery.ts +3 -1
  56. package/src/ui/screens/media-upload.ts +3 -2
@@ -0,0 +1,55 @@
1
+ // Every admin route answers a failed zod parse the same way:
2
+ //
3
+ // { error: 'Invalid request', details: { fieldErrors: { field: ['why'] }, formErrors: [...] } }
4
+ //
5
+ // The screens used to render only `error`, so an editor saw a bare
6
+ // "Invalid request" and had no way to learn which field was wrong or why. That
7
+ // stranded every video upload for over two hours when a source-URL rule started
8
+ // refusing the share links editors had always pasted: the message that said
9
+ // exactly what to change was in the response the whole time, unrendered.
10
+ //
11
+ // Read the field messages here, once, so every screen surfaces them.
12
+
13
+ export interface ApiErrorBody {
14
+ error?: string
15
+ details?: {
16
+ fieldErrors?: Record<string, string[] | undefined>
17
+ formErrors?: string[]
18
+ }
19
+ }
20
+
21
+ const MAX_RENDERED_FIELD_MESSAGES = 4
22
+
23
+ /**
24
+ * Build the message an editor should see from a failed admin-API response.
25
+ *
26
+ * Prefers the specific over the generic: field messages first (prefixed with
27
+ * their field so the editor knows where to look), then form-level messages,
28
+ * then the top-level `error`, then the caller's fallback. Never returns an
29
+ * empty string.
30
+ */
31
+ export function apiErrorMessage(body: ApiErrorBody | null | undefined, fallback: string): string {
32
+ const top = typeof body?.error === 'string' ? body.error.trim() : ''
33
+ const details = body?.details
34
+
35
+ const fieldMessages: string[] = []
36
+ for (const [field, messages] of Object.entries(details?.fieldErrors ?? {})) {
37
+ for (const message of messages ?? []) {
38
+ const text = typeof message === 'string' ? message.trim() : ''
39
+ if (text) fieldMessages.push(`${field}: ${text}`)
40
+ }
41
+ }
42
+ for (const message of details?.formErrors ?? []) {
43
+ const text = typeof message === 'string' ? message.trim() : ''
44
+ if (text) fieldMessages.push(text)
45
+ }
46
+
47
+ if (fieldMessages.length === 0) return top || fallback
48
+
49
+ const shown = fieldMessages.slice(0, MAX_RENDERED_FIELD_MESSAGES)
50
+ const hidden = fieldMessages.length - shown.length
51
+ const detail = shown.join('; ') + (hidden > 0 ? ` (+${hidden} more)` : '')
52
+ // Keep the top-level text when it adds something beyond the generic prefix
53
+ // every zod failure shares.
54
+ return top && top !== 'Invalid request' ? `${top}: ${detail}` : detail
55
+ }
@@ -23,6 +23,7 @@ import { type ContentMetadata, parseContentMetadata } from '../../engine/content
23
23
  import { countWords, estimateReadTime } from '../../engine/publisher.js'
24
24
  import type { ContentMetadataSelectField, PrimaryTopicControl } from '../../integration/options.js'
25
25
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
26
+ import { type ApiErrorBody, apiErrorMessage } from '../api-error.js'
26
27
  import { Icon } from '../icons.js'
27
28
  import { uploadMediaAsset } from '../screens/media-upload.js'
28
29
  import { AiAssistPanel } from './AiAssistPanel.js'
@@ -312,10 +313,8 @@ export function ContentForm({
312
313
  try {
313
314
  const res = await fetch(`/admin/api/content/${docId}`)
314
315
  if (!res.ok) {
315
- const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
316
- error?: string
317
- }
318
- throw new Error(err.error ?? 'Could not load content')
316
+ const err = (await res.json().catch(() => ({ error: res.statusText }))) as ApiErrorBody
317
+ throw new Error(apiErrorMessage(err, 'Could not load content'))
319
318
  }
320
319
  const data = (await res.json()) as ExistingContentResponse
321
320
  const next = contentResponseToFormState(data, contentType, docId)
@@ -381,10 +380,8 @@ export function ContentForm({
381
380
  method: 'POST',
382
381
  })
383
382
  if (!res.ok) {
384
- const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
385
- error?: string
386
- }
387
- throw new Error(err.error ?? 'Foundry dispatch failed')
383
+ const err = (await res.json().catch(() => ({ error: res.statusText }))) as ApiErrorBody
384
+ throw new Error(apiErrorMessage(err, 'Foundry dispatch failed'))
388
385
  }
389
386
  queuedVideoSources.current.add(queueKey)
390
387
  setForm((prev) => ({
@@ -434,13 +431,11 @@ export function ContentForm({
434
431
  body: JSON.stringify(buildContentCreatePayload(contentType, draft)),
435
432
  })
436
433
  if (!res.ok) {
437
- const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
438
- error?: string
439
- }
434
+ const err = (await res.json().catch(() => ({ error: res.statusText }))) as ApiErrorBody
440
435
  setForm((prev) => ({
441
436
  ...prev,
442
437
  saving: false,
443
- saveError: err.error ?? 'Save failed',
438
+ saveError: apiErrorMessage(err, 'Save failed'),
444
439
  }))
445
440
  return
446
441
  }
@@ -490,7 +485,7 @@ export function ContentForm({
490
485
  setForm((prev) => ({
491
486
  ...prev,
492
487
  saving: false,
493
- saveError: err.error ?? 'Save failed',
488
+ saveError: apiErrorMessage(err, 'Save failed'),
494
489
  }))
495
490
  return
496
491
  }
@@ -1,3 +1,4 @@
1
+ import { apiErrorMessage } from '../api-error.js'
1
2
  import { slugifyTitle } from './content-payload.js'
2
3
 
3
4
  type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
@@ -17,7 +18,7 @@ export async function uploadEditorImage(
17
18
  })
18
19
  if (!res.ok) {
19
20
  const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error?: string }
20
- throw new Error(err.error ?? 'Image upload failed')
21
+ throw new Error(apiErrorMessage(err, 'Image upload failed'))
21
22
  }
22
23
  const body = (await res.json()) as { url?: string; publicUrl?: string }
23
24
  const url = body.url || body.publicUrl
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { useCallback, useEffect, useState } from 'react'
14
14
  import type { ContentStatus } from '../../schema/types.js'
15
+ import { apiErrorMessage } from '../api-error.js'
15
16
  import { Icon } from '../icons.js'
16
17
  import { Field } from './Field.js'
17
18
  import {
@@ -246,7 +247,7 @@ export function PublishTab({
246
247
  const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
247
248
  error?: string
248
249
  }
249
- throw new Error(err.error ?? 'Could not add author')
250
+ throw new Error(apiErrorMessage(err, 'Could not add author'))
250
251
  }
251
252
  const created = (await res.json()) as CreatedAuthorResponse
252
253
  const nextAuthor = { id: created.id, name }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
14
14
  import type { ContentStatus, ContentType, FrontsPublishStatus } from '../../schema/types.js'
15
+ import { apiErrorMessage } from '../api-error.js'
15
16
  import { buildSharePrefill, ShareModal } from '../components/ShareModal.js'
16
17
  import { Icon } from '../icons.js'
17
18
  import { useWorkspace } from '../workspace-context.js'
@@ -1139,7 +1140,7 @@ function BulkBar({
1139
1140
  const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
1140
1141
  error?: string
1141
1142
  }
1142
- alert(`Bulk ${op} failed: ${err.error ?? res.statusText}`)
1143
+ alert(`Bulk ${op} failed: ${apiErrorMessage(err, res.statusText)}`)
1143
1144
  setBusy(false)
1144
1145
  return
1145
1146
  }
@@ -1241,7 +1242,7 @@ function ImportModal({
1241
1242
  const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
1242
1243
  error?: string
1243
1244
  }
1244
- setError(err.error ?? 'Parse failed')
1245
+ setError(apiErrorMessage(err, 'Parse failed'))
1245
1246
  return
1246
1247
  }
1247
1248
  const data = (await res.json()) as ParseResult
@@ -1266,7 +1267,7 @@ function ImportModal({
1266
1267
  const err = (await res.json().catch(() => ({ error: res.statusText }))) as {
1267
1268
  error?: string
1268
1269
  }
1269
- setError(err.error ?? 'Confirm failed')
1270
+ setError(apiErrorMessage(err, 'Confirm failed'))
1270
1271
  setStep('preview')
1271
1272
  return
1272
1273
  }
@@ -24,6 +24,7 @@
24
24
  import { useCallback, useEffect, useReducer, useState } from 'react'
25
25
  import type { ApiKeyListEntry } from '../../engine/api-keys.js'
26
26
  import { WEBHOOK_EVENTS, type WebhookRecord } from '../../engine/webhooks.js'
27
+ import { apiErrorMessage } from '../api-error.js'
27
28
  import { Icon } from '../icons.js'
28
29
  import { useWorkspace } from '../workspace-context.js'
29
30
  import {
@@ -552,7 +553,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
552
553
  const res = await fetch('/admin/api/settings/members')
553
554
  if (!res.ok) {
554
555
  const body = (await res.json().catch(() => ({}))) as { error?: string }
555
- dispatch({ type: 'fetch-error', error: body.error ?? `HTTP ${res.status}` })
556
+ dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
556
557
  return
557
558
  }
558
559
  const body = (await res.json()) as {
@@ -600,7 +601,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
600
601
  })
601
602
  if (!res.ok) {
602
603
  const body = (await res.json().catch(() => ({}))) as { error?: string; reason?: string }
603
- alert(body.reason ?? body.error ?? `Failed to change role (HTTP ${res.status})`)
604
+ alert(body.reason ?? apiErrorMessage(body, `Failed to change role (HTTP ${res.status})`))
604
605
  return
605
606
  }
606
607
  dispatch({ type: 'update-role', userId, role: newRole })
@@ -629,7 +630,7 @@ function TeamSettings({ currentSubject }: { currentSubject: string | null }) {
629
630
  error?: string
630
631
  }
631
632
  if (!res.ok) {
632
- setInviteError(body.error ?? `HTTP ${res.status}`)
633
+ setInviteError(apiErrorMessage(body, `HTTP ${res.status}`))
633
634
  return
634
635
  }
635
636
  setInviteSuccess(
@@ -905,7 +906,7 @@ function ApiSettings() {
905
906
  const res = await fetch('/admin/api/settings/api-keys')
906
907
  if (!res.ok) {
907
908
  const body = (await res.json().catch(() => ({}))) as { error?: string }
908
- dispatch({ type: 'fetch-error', error: body.error ?? `HTTP ${res.status}` })
909
+ dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
909
910
  return
910
911
  }
911
912
  const body = (await res.json()) as { apiKeys: ApiKeyListEntry[] }
@@ -937,7 +938,7 @@ function ApiSettings() {
937
938
  error?: string
938
939
  }
939
940
  if (!res.ok) {
940
- setCreateError(body.error ?? `HTTP ${res.status}`)
941
+ setCreateError(apiErrorMessage(body, `HTTP ${res.status}`))
941
942
  return
942
943
  }
943
944
  // Show the one-time reveal modal
@@ -965,7 +966,7 @@ function ApiSettings() {
965
966
  dispatch({ type: 'revoked', id })
966
967
  } else {
967
968
  const body = (await res.json().catch(() => ({}))) as { error?: string }
968
- alert(body.error ?? `Failed to revoke (HTTP ${res.status})`)
969
+ alert(apiErrorMessage(body, `Failed to revoke (HTTP ${res.status})`))
969
970
  }
970
971
  } catch (err) {
971
972
  alert(String(err))
@@ -1158,7 +1159,7 @@ function WebhooksSettings() {
1158
1159
  const res = await fetch('/admin/api/settings/webhooks')
1159
1160
  if (!res.ok) {
1160
1161
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1161
- dispatch({ type: 'fetch-error', error: body.error ?? `HTTP ${res.status}` })
1162
+ dispatch({ type: 'fetch-error', error: apiErrorMessage(body, `HTTP ${res.status}`) })
1162
1163
  return
1163
1164
  }
1164
1165
  const body = (await res.json()) as { webhooks: WebhookRecord[] }
@@ -1185,7 +1186,7 @@ function WebhooksSettings() {
1185
1186
  })
1186
1187
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1187
1188
  if (!res.ok) {
1188
- setCreateError(body.error ?? `HTTP ${res.status}`)
1189
+ setCreateError(apiErrorMessage(body, `HTTP ${res.status}`))
1189
1190
  return
1190
1191
  }
1191
1192
  setNewUrl('')
@@ -1206,7 +1207,7 @@ function WebhooksSettings() {
1206
1207
  })
1207
1208
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1208
1209
  if (!res.ok) {
1209
- alert(body.error ?? `Test failed (HTTP ${res.status})`)
1210
+ alert(apiErrorMessage(body, `Test failed (HTTP ${res.status})`))
1210
1211
  return
1211
1212
  }
1212
1213
  dispatch({ type: 'update-status', id, status: 'test' })
@@ -1228,7 +1229,7 @@ function WebhooksSettings() {
1228
1229
  dispatch({ type: 'deleted', id })
1229
1230
  } else {
1230
1231
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1231
- alert(body.error ?? `Failed to delete (HTTP ${res.status})`)
1232
+ alert(apiErrorMessage(body, `Failed to delete (HTTP ${res.status})`))
1232
1233
  }
1233
1234
  } catch (err) {
1234
1235
  alert(String(err))
@@ -1481,7 +1482,7 @@ function IntegrationsSettings() {
1481
1482
  .then(async (res) => {
1482
1483
  if (!res.ok) {
1483
1484
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1484
- setError(body.error ?? `HTTP ${res.status}`)
1485
+ setError(apiErrorMessage(body, `HTTP ${res.status}`))
1485
1486
  return
1486
1487
  }
1487
1488
  const body = (await res.json()) as IntegrationsData
@@ -1600,7 +1601,7 @@ function DomainsSettings() {
1600
1601
  .then(async (res) => {
1601
1602
  if (!res.ok) {
1602
1603
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1603
- setError(body.error ?? `HTTP ${res.status}`)
1604
+ setError(apiErrorMessage(body, `HTTP ${res.status}`))
1604
1605
  return
1605
1606
  }
1606
1607
  const body = (await res.json()) as DomainsData
@@ -1728,7 +1729,7 @@ export function SettingsScreen() {
1728
1729
  })
1729
1730
  if (!res.ok) {
1730
1731
  const body = (await res.json().catch(() => ({}))) as { error?: string }
1731
- alert(body.error ?? `Failed to save settings (HTTP ${res.status})`)
1732
+ alert(apiErrorMessage(body, `Failed to save settings (HTTP ${res.status})`))
1732
1733
  return
1733
1734
  }
1734
1735
  // Re-fetch to ensure the UI is in sync with server state
@@ -30,6 +30,7 @@
30
30
  // confirm dialog. NO classes outside the broadsheet vocabulary.
31
31
 
32
32
  import { useCallback, useEffect, useReducer, useState } from 'react'
33
+ import { apiErrorMessage } from '../api-error.js'
33
34
  import { Icon } from '../icons.js'
34
35
  import {
35
36
  buildTopicCreateBody,
@@ -191,7 +192,7 @@ export function TopicsScreen() {
191
192
  error?: string
192
193
  }
193
194
  if (!res.ok || !body.topic) {
194
- setCreateError(body.error || `${res.status} ${res.statusText}`)
195
+ setCreateError(apiErrorMessage(body, `${res.status} ${res.statusText}`))
195
196
  return
196
197
  }
197
198
  dispatchTopics({ type: 'create-ok', topic: body.topic })
@@ -219,7 +220,7 @@ export function TopicsScreen() {
219
220
  error?: string
220
221
  }
221
222
  if (!res.ok || !body.topic) {
222
- setRenameError(body.error || `${res.status} ${res.statusText}`)
223
+ setRenameError(apiErrorMessage(body, `${res.status} ${res.statusText}`))
223
224
  return
224
225
  }
225
226
  dispatchTopics({ type: 'rename-ok', fromSlug: pendingRename.slug, topic: body.topic })
@@ -1,3 +1,5 @@
1
+ import { apiErrorMessage } from '../api-error.js'
2
+
1
3
  interface AuthorWriteFailureBody {
2
4
  error?: string
3
5
  errorClass?: string
@@ -17,7 +19,7 @@ export function formatAuthorWriteFailure(
17
19
  body.author?.id ? `author id: ${body.author.id}` : '',
18
20
  body.author?.slug ? `slug: ${body.author.slug}` : '',
19
21
  ].filter(Boolean)
20
- const message = body.error || 'Author synchronization failed'
22
+ const message = apiErrorMessage(body, 'Author synchronization failed')
21
23
  const classifiedMessage = message.includes(body.errorClass)
22
24
  ? message
23
25
  : `${body.errorClass}: ${message}`
@@ -1,3 +1,4 @@
1
+ import { type ApiErrorBody, apiErrorMessage } from '../api-error.js'
1
2
  export type MediaUploadKind = 'image' | 'video' | 'podcast'
2
3
 
3
4
  export interface MediaUploadResult {
@@ -97,9 +98,9 @@ async function readJson<T>(response: Response): Promise<T> {
97
98
  }
98
99
 
99
100
  async function assertOk<T>(response: Response, fallback: string): Promise<T> {
100
- const body = await readJson<T & { error?: string }>(response)
101
+ const body = await readJson<T & ApiErrorBody>(response)
101
102
  if (!response.ok) {
102
- throw new Error(body.error || fallback)
103
+ throw new Error(apiErrorMessage(body, fallback))
103
104
  }
104
105
  return body
105
106
  }