@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.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/lib/page-audit-input.test.js +10 -0
- package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
- package/dist/__tests__/lib/page-editor-service.test.js +7 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service-api.test.js +1 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +25 -6
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +27 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -1
- package/dist/__tests__/lib/workflow-chrome.test.js +4 -0
- package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +19 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +1 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.js +1 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
- package/dist/lib/page-audit-input.js +1 -1
- package/dist/lib/page-audit-input.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +18 -3
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +33 -13
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +3 -9
- package/dist/lib/version-history.js.map +1 -1
- package/dist/lib/workflow-chrome.d.ts +3 -0
- package/dist/lib/workflow-chrome.d.ts.map +1 -1
- package/dist/lib/workflow-chrome.js +9 -0
- package/dist/lib/workflow-chrome.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +1 -1
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.d.ts +3 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.js +7 -2
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +15 -2
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +3 -3
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts.map +1 -1
- package/dist/views/post-editor/PostTab.js +1 -0
- package/dist/views/post-editor/PostTab.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/page-audit-input.test.ts +11 -0
- package/src/__tests__/lib/page-editor-service.test.ts +8 -0
- package/src/__tests__/lib/post-editor-service-api.test.ts +1 -0
- package/src/__tests__/lib/post-editor-service.test.ts +26 -9
- package/src/__tests__/lib/version-history.test.ts +29 -0
- package/src/__tests__/lib/workflow-chrome.test.ts +5 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +39 -0
- package/src/__tests__/views/post-section-editor.test.tsx +1 -0
- package/src/__tests__/views/post-tab.render.test.tsx +1 -0
- package/src/lib/page-audit-input.ts +1 -1
- package/src/lib/post-editor-service.ts +45 -10
- package/src/lib/version-history.ts +3 -7
- package/src/lib/workflow-chrome.ts +10 -0
- package/src/views/page-editor/EditorTopBar.tsx +1 -1
- package/src/views/post-editor/PostFieldsForm.tsx +14 -5
- package/src/views/post-editor/PostFieldsModal.tsx +15 -2
- package/src/views/post-editor/PostSectionEditor.tsx +3 -3
- 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
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
225
|
-
if (typeof raw === 'string')
|
|
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
|
-
|
|
229
|
-
const url = obj.url ?? obj.publicUrl ?? obj.src
|
|
230
|
-
|
|
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:
|
|
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
|
-
|
|
101
|
-
|
|
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…' :
|
|
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.
|
|
292
|
+
value={values.featuredImageUrl}
|
|
290
293
|
disabled={!canEdit}
|
|
291
294
|
placeholder="https://… or pick from the media library"
|
|
292
|
-
onChange={(e) =>
|
|
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.
|
|
309
|
+
{values.featuredImageUrl && (
|
|
304
310
|
<div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
|
|
305
|
-
<img src={values.
|
|
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({
|
|
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 = {
|
|
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?.
|
|
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?.
|
|
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.
|
|
844
|
+
featuredImage={post.featuredImageUrl}
|
|
845
845
|
draftMeta={{
|
|
846
846
|
seoTitle: post.seoTitle,
|
|
847
847
|
seoDescription: post.seoDescription,
|