@prenta/admin 1.6.0 → 1.6.2

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 (68) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/lib/page-audit-input.test.js +10 -0
  3. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  4. package/dist/__tests__/lib/page-editor-service.test.js +7 -0
  5. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  6. package/dist/__tests__/lib/post-editor-service-api.test.js +1 -0
  7. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -1
  8. package/dist/__tests__/lib/post-editor-service.test.js +25 -6
  9. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  10. package/dist/__tests__/lib/version-history.test.js +27 -0
  11. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  12. package/dist/__tests__/lib/workflow-chrome.test.js +4 -0
  13. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -1
  14. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  15. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  16. package/dist/__tests__/views/post-fields-modal.render.test.js +19 -0
  17. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  18. package/dist/__tests__/views/post-section-editor.test.js +1 -0
  19. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  20. package/dist/__tests__/views/post-tab.render.test.js +1 -0
  21. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  22. package/dist/lib/page-audit-input.js +1 -1
  23. package/dist/lib/page-audit-input.js.map +1 -1
  24. package/dist/lib/post-editor-service.d.ts +18 -3
  25. package/dist/lib/post-editor-service.d.ts.map +1 -1
  26. package/dist/lib/post-editor-service.js +33 -13
  27. package/dist/lib/post-editor-service.js.map +1 -1
  28. package/dist/lib/version-history.d.ts.map +1 -1
  29. package/dist/lib/version-history.js +3 -9
  30. package/dist/lib/version-history.js.map +1 -1
  31. package/dist/lib/workflow-chrome.d.ts +3 -0
  32. package/dist/lib/workflow-chrome.d.ts.map +1 -1
  33. package/dist/lib/workflow-chrome.js +9 -0
  34. package/dist/lib/workflow-chrome.js.map +1 -1
  35. package/dist/views/page-editor/EditorTopBar.js +1 -1
  36. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  37. package/dist/views/post-editor/PostFieldsForm.d.ts +3 -0
  38. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -1
  39. package/dist/views/post-editor/PostFieldsForm.js +7 -2
  40. package/dist/views/post-editor/PostFieldsForm.js.map +1 -1
  41. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  42. package/dist/views/post-editor/PostFieldsModal.js +15 -2
  43. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  44. package/dist/views/post-editor/PostSectionEditor.js +3 -3
  45. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  46. package/dist/views/post-editor/PostTab.d.ts.map +1 -1
  47. package/dist/views/post-editor/PostTab.js +1 -0
  48. package/dist/views/post-editor/PostTab.js.map +1 -1
  49. package/package.json +3 -3
  50. package/src/__tests__/lib/page-audit-input.test.ts +11 -0
  51. package/src/__tests__/lib/page-editor-service.test.ts +8 -0
  52. package/src/__tests__/lib/post-editor-service-api.test.ts +1 -0
  53. package/src/__tests__/lib/post-editor-service.test.ts +26 -9
  54. package/src/__tests__/lib/version-history.test.ts +29 -0
  55. package/src/__tests__/lib/workflow-chrome.test.ts +5 -0
  56. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  57. package/src/__tests__/views/post-fields-modal.render.test.tsx +39 -0
  58. package/src/__tests__/views/post-section-editor.test.tsx +1 -0
  59. package/src/__tests__/views/post-tab.render.test.tsx +1 -0
  60. package/src/lib/page-audit-input.ts +1 -1
  61. package/src/lib/post-editor-service.ts +45 -10
  62. package/src/lib/version-history.ts +3 -7
  63. package/src/lib/workflow-chrome.ts +10 -0
  64. package/src/views/page-editor/EditorTopBar.tsx +1 -1
  65. package/src/views/post-editor/PostFieldsForm.tsx +14 -5
  66. package/src/views/post-editor/PostFieldsModal.tsx +15 -2
  67. package/src/views/post-editor/PostSectionEditor.tsx +3 -3
  68. package/src/views/post-editor/PostTab.tsx +1 -0
@@ -74,7 +74,10 @@ export interface EditorPost {
74
74
  title: string
75
75
  slug: string
76
76
  excerpt: string
77
+ /** Persist identity — media id when present so usage tracking survives (#671). */
77
78
  featuredImage: string
79
+ /** Public URL for `<img src>` and the details input. Empty when only an id is known. */
80
+ featuredImageUrl: string
78
81
  body: string
79
82
  category: string
80
83
  /** Editorial tags as plain strings (stored as `[{ tag }]` rows, see {@link parseTags}). */
@@ -216,20 +219,49 @@ function dataStr(data: Record<string, unknown>, key: string): string {
216
219
  return typeof v === 'string' ? v : ''
217
220
  }
218
221
 
222
+ export interface FeaturedImageRef {
223
+ /** Persist this — media id when present so usage tracking survives (#671). */
224
+ id: string
225
+ /** Use this for `<img src>` and the details input. Empty when only an id is known. */
226
+ url: string
227
+ }
228
+
229
+ /** True for paths and http(s) URLs — never a bare media cuid. */
230
+ export function isDisplayableImageSrc(value: string): boolean {
231
+ const v = value.trim()
232
+ return v.startsWith('/') || /^https?:\/\//i.test(v)
233
+ }
234
+
235
+ function asDisplayUrl(value: unknown): string {
236
+ if (typeof value !== 'string') return ''
237
+ const v = value.trim()
238
+ return isDisplayableImageSrc(v) ? v : ''
239
+ }
240
+
219
241
  /**
220
- * Media fields are stored denormalized as `{ id, url, alt, }` after headless
221
- * writes / admin picker. The editor state is a string (id or URL). Never coerce
222
- * an object to `""` that wiped featured images on the next save (#671).
242
+ * Split a stored featured-image value into persist identity and display URL.
243
+ *
244
+ * Media fields are denormalized as `{ id, url, alt, }` after headless writes
245
+ * / the admin picker. Never coerce an object to `""` — that wiped featured
246
+ * images on the next save (#671).
223
247
  */
224
- export function parseFeaturedImage(raw: unknown): string {
225
- if (typeof raw === 'string') return raw.trim()
248
+ export function parseFeaturedImageRef(raw: unknown): FeaturedImageRef {
249
+ if (typeof raw === 'string') {
250
+ const value = raw.trim()
251
+ return { id: value, url: asDisplayUrl(value) }
252
+ }
226
253
  if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
227
254
  const obj = raw as Record<string, unknown>
228
- if (typeof obj.id === 'string' && obj.id.trim()) return obj.id.trim()
229
- const url = obj.url ?? obj.publicUrl ?? obj.src
230
- if (typeof url === 'string' && url.trim()) return url.trim()
255
+ const id = typeof obj.id === 'string' ? obj.id.trim() : ''
256
+ const url = asDisplayUrl(obj.url ?? obj.publicUrl ?? obj.src)
257
+ return { id: id || url, url }
231
258
  }
232
- return ''
259
+ return { id: '', url: '' }
260
+ }
261
+
262
+ /** Persist identity only — prefer `id` so a save does not drop the media link. */
263
+ export function parseFeaturedImage(raw: unknown): string {
264
+ return parseFeaturedImageRef(raw).id
233
265
  }
234
266
 
235
267
  /**
@@ -255,6 +287,7 @@ export function parseTags(raw: unknown): string[] {
255
287
  function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
256
288
  const data = doc.data ?? {}
257
289
  const dataTitle = dataStr(data, 'title')
290
+ const featured = parseFeaturedImageRef(data.featuredImage)
258
291
  return {
259
292
  id: doc.id,
260
293
  postType,
@@ -263,7 +296,8 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
263
296
  title: dataTitle || (doc.title ?? ''),
264
297
  slug: doc.slug ?? dataStr(data, 'slug'),
265
298
  excerpt: dataStr(data, 'excerpt'),
266
- featuredImage: parseFeaturedImage(data.featuredImage),
299
+ featuredImage: featured.id,
300
+ featuredImageUrl: featured.url,
267
301
  body: dataStr(data, 'body'),
268
302
  category: dataStr(data, 'category'),
269
303
  tags: parseTags(data.tags),
@@ -457,6 +491,7 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
457
491
  slug: '',
458
492
  excerpt: '',
459
493
  featuredImage: '',
494
+ featuredImageUrl: '',
460
495
  body: '',
461
496
  category: '',
462
497
  tags: [],
@@ -9,6 +9,7 @@
9
9
  * response and no second request per row is needed.
10
10
  */
11
11
  import { cmsApi } from './api.js'
12
+ import { parseFeaturedImageRef } from './post-editor-service.js'
12
13
  import { coerceSections, type PageSection } from '@prenta/core/page-sections'
13
14
 
14
15
  /**
@@ -97,13 +98,8 @@ function optionalString(value: unknown): string | undefined {
97
98
  }
98
99
 
99
100
  function optionalFeaturedImage(value: unknown): string | undefined {
100
- if (typeof value === 'string') return value
101
- if (value && typeof value === 'object' && !Array.isArray(value)) {
102
- const obj = value as Record<string, unknown>
103
- const url = obj.url ?? obj.publicUrl ?? obj.src
104
- if (typeof url === 'string') return url
105
- }
106
- return undefined
101
+ const url = parseFeaturedImageRef(value).url
102
+ return url || undefined
107
103
  }
108
104
 
109
105
  export function toPageVersion(row: VersionApiRow): PageVersion {
@@ -8,6 +8,8 @@ export const REQUEST_CHANGES_LABEL = 'Request changes'
8
8
  export const WAITING_FOR_REVIEW_LABEL = 'Waiting for review.'
9
9
  export const REVIEWER_NOTE_PREFIX = 'Reviewer note:'
10
10
  export const SUBMIT_BEFORE_PUBLISH_ERROR = 'Submit for review before publishing.'
11
+ export const SAVE_DRAFT_LABEL = 'Save Draft'
12
+ export const UPDATE_LIVE_LABEL = 'Update live'
11
13
 
12
14
  const REVIEWER_ROLES = new Set(['ADMIN', 'EDITOR'])
13
15
  const SUBMIT_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR'])
@@ -33,12 +35,14 @@ export function resolveWorkflowChrome(input: {
33
35
  waitingLabel?: string
34
36
  statusLabel: string
35
37
  statusClassName: string
38
+ saveLabel: string
36
39
  } {
37
40
  const role = input.role.toUpperCase()
38
41
  const published = input.status === 'PUBLISHED'
39
42
  const scheduled = input.status === 'SCHEDULED'
40
43
  const pillKey = published ? 'PUBLISHED' : scheduled ? 'SCHEDULED' : input.stage
41
44
  const pill = STATUS_PILL[pillKey]
45
+ const saveLabel = published ? UPDATE_LIVE_LABEL : SAVE_DRAFT_LABEL
42
46
 
43
47
  if (!input.canEdit || published) {
44
48
  return {
@@ -47,6 +51,7 @@ export function resolveWorkflowChrome(input: {
47
51
  showSchedule: false,
48
52
  statusLabel: pill.label,
49
53
  statusClassName: pill.className,
54
+ saveLabel,
50
55
  }
51
56
  }
52
57
 
@@ -61,6 +66,7 @@ export function resolveWorkflowChrome(input: {
61
66
  showSchedule: reviewer,
62
67
  statusLabel: pill.label,
63
68
  statusClassName: pill.className,
69
+ saveLabel,
64
70
  }
65
71
  }
66
72
 
@@ -71,6 +77,7 @@ export function resolveWorkflowChrome(input: {
71
77
  showSchedule: false,
72
78
  statusLabel: pill.label,
73
79
  statusClassName: pill.className,
80
+ saveLabel,
74
81
  }
75
82
  }
76
83
 
@@ -81,6 +88,7 @@ export function resolveWorkflowChrome(input: {
81
88
  showSchedule: true,
82
89
  statusLabel: pill.label,
83
90
  statusClassName: pill.className,
91
+ saveLabel,
84
92
  }
85
93
  }
86
94
 
@@ -92,6 +100,7 @@ export function resolveWorkflowChrome(input: {
92
100
  waitingLabel: WAITING_FOR_REVIEW_LABEL,
93
101
  statusLabel: pill.label,
94
102
  statusClassName: pill.className,
103
+ saveLabel,
95
104
  }
96
105
  }
97
106
 
@@ -101,5 +110,6 @@ export function resolveWorkflowChrome(input: {
101
110
  showSchedule: false,
102
111
  statusLabel: pill.label,
103
112
  statusClassName: pill.className,
113
+ saveLabel,
104
114
  }
105
115
  }
@@ -403,7 +403,7 @@ export function EditorTopBar({
403
403
  disabled={!canEdit || saveState === 'saving' || (!dirty && saveState !== 'error')}
404
404
  className="border-border text-foreground hover:bg-accent rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
405
405
  >
406
- {saveState === 'saving' ? 'Saving…' : 'Save Draft'}
406
+ {saveState === 'saving' ? 'Saving…' : workflow.saveLabel}
407
407
  </button>
408
408
 
409
409
  {onSchedule && workflow.showSchedule && (
@@ -23,7 +23,10 @@ export interface PostFieldsFormValues {
23
23
  title: string
24
24
  slug: string
25
25
  excerpt: string
26
+ /** Persist identity (media id when known). */
26
27
  featuredImage: string
28
+ /** Public URL shown in the input and used as `<img src>`. */
29
+ featuredImageUrl: string
27
30
  category: string
28
31
  tags: string[]
29
32
  }
@@ -286,10 +289,13 @@ export function PostFieldsForm({
286
289
  <input
287
290
  id={imageId}
288
291
  className={`${INPUT} min-w-0 flex-1`}
289
- value={values.featuredImage}
292
+ value={values.featuredImageUrl}
290
293
  disabled={!canEdit}
291
294
  placeholder="https://… or pick from the media library"
292
- onChange={(e) => onChange({ featuredImage: e.target.value })}
295
+ onChange={(e) => {
296
+ const next = e.target.value
297
+ onChange({ featuredImage: next, featuredImageUrl: next })
298
+ }}
293
299
  />
294
300
  <button
295
301
  type="button"
@@ -300,9 +306,9 @@ export function PostFieldsForm({
300
306
  Browse
301
307
  </button>
302
308
  </div>
303
- {values.featuredImage && (
309
+ {values.featuredImageUrl && (
304
310
  <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
305
- <img src={values.featuredImage} alt="" className="h-28 w-full object-cover" />
311
+ <img src={values.featuredImageUrl} alt="" className="h-28 w-full object-cover" />
306
312
  </div>
307
313
  )}
308
314
  </div>
@@ -311,8 +317,11 @@ export function PostFieldsForm({
311
317
  <MediaPickerModal
312
318
  open={mediaOpen}
313
319
  onClose={() => setMediaOpen(false)}
320
+ onSelectItem={(item) => {
321
+ onChange({ featuredImage: item.id || item.url, featuredImageUrl: item.url })
322
+ }}
314
323
  onSelect={(url) => {
315
- onChange({ featuredImage: url })
324
+ onChange({ featuredImageUrl: url })
316
325
  setMediaOpen(false)
317
326
  }}
318
327
  accept="image/*"
@@ -29,6 +29,7 @@ type FormSnapshot = {
29
29
  slug: string
30
30
  excerpt: string
31
31
  featuredImage: string
32
+ featuredImageUrl: string
32
33
  category: string
33
34
  tags: string
34
35
  }
@@ -39,6 +40,7 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
39
40
  slug: post.slug,
40
41
  excerpt: post.excerpt,
41
42
  featuredImage: post.featuredImage,
43
+ featuredImageUrl: post.featuredImageUrl,
42
44
  category: post.category,
43
45
  tags: [...post.tags].sort().join('\0'),
44
46
  }
@@ -64,6 +66,7 @@ export function PostFieldsModal({
64
66
  const [slug, setSlug] = useState(post.slug)
65
67
  const [excerpt, setExcerpt] = useState(post.excerpt)
66
68
  const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
69
+ const [featuredImageUrl, setFeaturedImageUrl] = useState(post.featuredImageUrl)
67
70
  const [category, setCategory] = useState(post.category)
68
71
  const [tags, setTags] = useState<string[]>(post.tags)
69
72
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
@@ -82,13 +85,22 @@ export function PostFieldsModal({
82
85
  setSlug(post.slug)
83
86
  setExcerpt(post.excerpt)
84
87
  setFeaturedImage(post.featuredImage)
88
+ setFeaturedImageUrl(post.featuredImageUrl)
85
89
  setCategory(post.category)
86
90
  setTags(post.tags)
87
91
  setSlugTouched(Boolean(post.slug))
88
92
  baselineRef.current = snapshotFromPost(post)
89
93
  }, [open, post])
90
94
 
91
- const values: PostFieldsFormValues = { title, slug, excerpt, featuredImage, category, tags }
95
+ const values: PostFieldsFormValues = {
96
+ title,
97
+ slug,
98
+ excerpt,
99
+ featuredImage,
100
+ featuredImageUrl,
101
+ category,
102
+ tags,
103
+ }
92
104
 
93
105
  const isDirty = useMemo(() => {
94
106
  if (!open || !baselineRef.current) return false
@@ -108,6 +120,7 @@ export function PostFieldsModal({
108
120
  if (patch.slug !== undefined) setSlug(patch.slug)
109
121
  if (patch.excerpt !== undefined) setExcerpt(patch.excerpt)
110
122
  if (patch.featuredImage !== undefined) setFeaturedImage(patch.featuredImage)
123
+ if (patch.featuredImageUrl !== undefined) setFeaturedImageUrl(patch.featuredImageUrl)
111
124
  if (patch.category !== undefined) setCategory(patch.category)
112
125
  if (patch.tags !== undefined) setTags(patch.tags)
113
126
  }
@@ -116,7 +129,7 @@ export function PostFieldsModal({
116
129
 
117
130
  function handleSave() {
118
131
  if (slugError) return
119
- onSave({ title, slug, excerpt, featuredImage, category, tags })
132
+ onSave({ title, slug, excerpt, featuredImage, featuredImageUrl, category, tags })
120
133
  onClose()
121
134
  }
122
135
 
@@ -226,7 +226,7 @@ export function PostSectionEditor({
226
226
  post: {
227
227
  title: post?.title,
228
228
  bodyHtml: post?.body,
229
- featuredImage: post?.featuredImage,
229
+ featuredImage: post?.featuredImageUrl,
230
230
  },
231
231
  })
232
232
  const audit = liveAudit ?? savedAudit
@@ -338,7 +338,7 @@ export function PostSectionEditor({
338
338
  title: post?.title,
339
339
  excerpt: post?.excerpt,
340
340
  body: post?.body,
341
- featuredImageUrl: post?.featuredImage,
341
+ featuredImageUrl: post?.featuredImageUrl,
342
342
  publishDate: post?.publishDate ?? post?.updatedAt ?? null,
343
343
  category: post?.category,
344
344
  author: { name: session?.user?.name || session?.user?.email },
@@ -841,7 +841,7 @@ export function PostSectionEditor({
841
841
  contentHtml={seoContentHtml}
842
842
  title={post.title}
843
843
  bodyHtml={post.body}
844
- featuredImage={post.featuredImage}
844
+ featuredImage={post.featuredImageUrl}
845
845
  draftMeta={{
846
846
  seoTitle: post.seoTitle,
847
847
  seoDescription: post.seoDescription,
@@ -30,6 +30,7 @@ export function PostTab({ post, canEdit, fields, onChange }: PostTabProps) {
30
30
  slug: post.slug,
31
31
  excerpt: post.excerpt,
32
32
  featuredImage: post.featuredImage,
33
+ featuredImageUrl: post.featuredImageUrl,
33
34
  category: post.category,
34
35
  tags: post.tags,
35
36
  }}