@prenta/admin 1.17.11 → 1.17.13

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 (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/lib/post-canvas-context.test.d.ts +2 -0
  3. package/dist/__tests__/lib/post-canvas-context.test.d.ts.map +1 -0
  4. package/dist/__tests__/lib/post-canvas-context.test.js +40 -0
  5. package/dist/__tests__/lib/post-canvas-context.test.js.map +1 -0
  6. package/dist/__tests__/lib/post-editor-service-api.test.js +2 -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 +21 -0
  9. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  10. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  11. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  12. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  13. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  14. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  15. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  16. package/dist/__tests__/views/section-edit-form.render.test.js +36 -0
  17. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -1
  18. package/dist/lib/post-canvas-context.d.ts +38 -0
  19. package/dist/lib/post-canvas-context.d.ts.map +1 -0
  20. package/dist/lib/post-canvas-context.js +65 -0
  21. package/dist/lib/post-canvas-context.js.map +1 -0
  22. package/dist/lib/post-editor-service.d.ts +8 -0
  23. package/dist/lib/post-editor-service.d.ts.map +1 -1
  24. package/dist/lib/post-editor-service.js +5 -0
  25. package/dist/lib/post-editor-service.js.map +1 -1
  26. package/dist/views/page-editor/SectionEditForm.js +1 -1
  27. package/dist/views/page-editor/SectionEditForm.js.map +1 -1
  28. package/dist/views/page-editor/SectionInspector.js +1 -1
  29. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  30. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  31. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  32. package/dist/views/post-editor/PostHeader.d.ts +1 -1
  33. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  34. package/dist/views/post-editor/PostHeader.js +2 -0
  35. package/dist/views/post-editor/PostHeader.js.map +1 -1
  36. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
  37. package/dist/views/post-editor/PostHeaderPanel.js +2 -2
  38. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  39. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  40. package/dist/views/post-editor/PostSectionEditor.js +31 -2
  41. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  42. package/package.json +6 -6
  43. package/src/__tests__/lib/post-canvas-context.test.ts +56 -0
  44. package/src/__tests__/lib/post-editor-service-api.test.ts +2 -0
  45. package/src/__tests__/lib/post-editor-service.test.ts +22 -0
  46. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  47. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  48. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  49. package/src/__tests__/views/section-edit-form.render.test.tsx +55 -0
  50. package/src/lib/post-canvas-context.ts +80 -0
  51. package/src/lib/post-editor-service.ts +12 -0
  52. package/src/views/page-editor/SectionEditForm.tsx +2 -2
  53. package/src/views/page-editor/SectionInspector.tsx +2 -2
  54. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  55. package/src/views/post-editor/PostHeader.tsx +1 -0
  56. package/src/views/post-editor/PostHeaderPanel.tsx +30 -22
  57. package/src/views/post-editor/PostSectionEditor.tsx +46 -2
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ canvasAuthorName,
5
+ relatedPostsFromField,
6
+ relatedPostsFromSiblings,
7
+ } from '../../lib/post-canvas-context.js'
8
+
9
+ describe('canvasAuthorName', () => {
10
+ it('prefers the document author over the logged-in editor', () => {
11
+ expect(canvasAuthorName('Ada Lovelace', { name: 'Editor', email: 'ed@example.com' })).toBe(
12
+ 'Ada Lovelace',
13
+ )
14
+ })
15
+
16
+ it('falls back to the session when the document author is blank', () => {
17
+ expect(canvasAuthorName(' ', { name: '', email: 'ed@example.com' })).toBe('ed@example.com')
18
+ expect(canvasAuthorName(undefined, { name: 'Editor' })).toBe('Editor')
19
+ })
20
+ })
21
+
22
+ describe('related posts for the canvas', () => {
23
+ it('uses hand-picked posts when they already have titles', () => {
24
+ expect(
25
+ relatedPostsFromField([{ title: 'Keep going', excerpt: 'A note', url: '/blog/keep-going' }]),
26
+ ).toEqual([{ title: 'Keep going', excerpt: 'A note', url: '/blog/keep-going' }])
27
+ })
28
+
29
+ it('returns null for id-only relations so the editor can load siblings', () => {
30
+ expect(relatedPostsFromField(['post_1', 'post_2'])).toBeNull()
31
+ expect(relatedPostsFromField(undefined)).toBeNull()
32
+ })
33
+
34
+ it('builds sibling cards from data.title and the collection prefix', () => {
35
+ const cards = relatedPostsFromSiblings(
36
+ [
37
+ {
38
+ id: 'self',
39
+ title: 'Slug-Style-Title',
40
+ slug: 'self',
41
+ data: { title: 'This post' },
42
+ },
43
+ {
44
+ id: 'other',
45
+ title: 'Foster-Independence',
46
+ slug: 'foster-independence',
47
+ data: { title: 'Foster independence', excerpt: 'A summary' },
48
+ },
49
+ ],
50
+ { excludeId: 'self', urlPrefix: 'blog', collection: 'posts' },
51
+ )
52
+ expect(cards).toEqual([
53
+ { title: 'Foster independence', excerpt: 'A summary', url: '/blog/foster-independence' },
54
+ ])
55
+ })
56
+ })
@@ -26,6 +26,8 @@ function draft(id: string | null) {
26
26
  featuredImageUrl: '',
27
27
  body: '',
28
28
  category: '',
29
+ authorName: '',
30
+ canvasRelatedPosts: null,
29
31
  tags: [],
30
32
  status: 'DRAFT' as const,
31
33
  workflowStage: 'DRAFT' as const,
@@ -33,6 +33,8 @@ function basePost(over: Partial<EditorPostT> = {}): EditorPostT {
33
33
  featuredImageUrl: '',
34
34
  body: '',
35
35
  category: '',
36
+ authorName: '',
37
+ canvasRelatedPosts: null,
36
38
  tags: [],
37
39
  status: 'DRAFT',
38
40
  workflowStage: 'DRAFT',
@@ -314,6 +316,26 @@ describe('unmanaged section round-trip (data-loss regression)', () => {
314
316
  expect(post.title).toBe('Foster independence in children with autism')
315
317
  })
316
318
 
319
+ it('fetchPostForEditor reads authorName and expanded related posts', async () => {
320
+ cmsApiMock.mockResolvedValueOnce({
321
+ data: {
322
+ id: 'p1',
323
+ title: 'Logan',
324
+ slug: 'logan',
325
+ status: 'DRAFT',
326
+ data: {
327
+ title: 'Logan',
328
+ authorName: 'Ada Lovelace',
329
+ relatedPosts: [{ title: 'Next', url: '/blog/next' }],
330
+ sections: [],
331
+ },
332
+ },
333
+ })
334
+ const post = await fetchPostForEditor('blog', 'p1')
335
+ expect(post.authorName).toBe('Ada Lovelace')
336
+ expect(post.canvasRelatedPosts).toEqual([{ title: 'Next', url: '/blog/next' }])
337
+ })
338
+
317
339
  it('fetchPostForEditor preserves unknown section types as unmanaged', async () => {
318
340
  cmsApiMock.mockResolvedValueOnce({
319
341
  data: {
@@ -42,6 +42,8 @@ function basePost(over: Partial<ModalProps['post']> = {}): ModalProps['post'] {
42
42
  featuredImageUrl: '',
43
43
  body: '',
44
44
  category: '',
45
+ authorName: '',
46
+ canvasRelatedPosts: null,
45
47
  tags: [],
46
48
  status: 'DRAFT',
47
49
  workflowStage: 'DRAFT',
@@ -52,6 +52,8 @@ function loadedPost(
52
52
  featuredImageUrl: '',
53
53
  body: '',
54
54
  category: '',
55
+ authorName: '',
56
+ canvasRelatedPosts: [],
55
57
  tags: [],
56
58
  status: 'DRAFT',
57
59
  workflowStage: 'DRAFT',
@@ -30,6 +30,8 @@ function basePost(over: Partial<EditorPost> = {}): EditorPost {
30
30
  featuredImageUrl: '',
31
31
  body: '',
32
32
  category: '',
33
+ authorName: '',
34
+ canvasRelatedPosts: null,
33
35
  tags: [],
34
36
  status: 'DRAFT',
35
37
  workflowStage: 'DRAFT',
@@ -5,6 +5,26 @@ import { SectionEditForm } from '../../views/page-editor/SectionEditForm.js'
5
5
  import { SectionsList } from '../../views/page-editor/SectionsPanel.js'
6
6
  import type { PageSection } from '../../lib/page-editor-service.js'
7
7
 
8
+ vi.mock('../../fields/RichTextField.js', () => ({
9
+ RichTextField: ({
10
+ label,
11
+ value,
12
+ onChange,
13
+ helpText,
14
+ }: {
15
+ label: string
16
+ value?: string
17
+ onChange: (value: string) => void
18
+ helpText?: string
19
+ }) => (
20
+ <label>
21
+ {label}
22
+ <textarea aria-label={label} value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
23
+ {helpText ? <span>{helpText}</span> : null}
24
+ </label>
25
+ ),
26
+ }))
27
+
8
28
  afterEach(cleanup)
9
29
 
10
30
  function section(over: Partial<PageSection> = {}): PageSection {
@@ -85,6 +105,41 @@ describe('SectionEditForm — content only', () => {
85
105
  expect(screen.queryByLabelText('Section name')).toBeNull()
86
106
  })
87
107
 
108
+ it('edits the post Body field when an article body uses that source (#519)', () => {
109
+ const onPostBodyChange = vi.fn()
110
+ const { onContentChange } = renderForm({
111
+ section: section({
112
+ sectionType: 'article-body',
113
+ name: 'Article Body',
114
+ content: { source: 'field', body: '<p>custom</p>', width: 'normal', showToc: false },
115
+ }),
116
+ postBody: '<p>Hello</p>',
117
+ onPostBodyChange,
118
+ })
119
+ expect(screen.getByLabelText('Post body field')).toBeTruthy()
120
+ expect(screen.getByText(/Shared with post settings/i)).toBeTruthy()
121
+ expect(screen.queryByLabelText('Custom body')).toBeNull()
122
+ fireEvent.change(screen.getByLabelText('Post body field'), {
123
+ target: { value: '<p>Next</p>' },
124
+ })
125
+ expect(onPostBodyChange).toHaveBeenCalledWith('<p>Next</p>')
126
+ expect(onContentChange).not.toHaveBeenCalled()
127
+ })
128
+
129
+ it('keeps Custom body when the article body source is custom copy', () => {
130
+ renderForm({
131
+ section: section({
132
+ sectionType: 'article-body',
133
+ name: 'Article Body',
134
+ content: { source: 'override', body: '<p>Only here</p>' },
135
+ }),
136
+ postBody: '<p>Hello</p>',
137
+ onPostBodyChange: vi.fn(),
138
+ })
139
+ expect(screen.queryByLabelText('Post body field')).toBeNull()
140
+ expect(screen.getByLabelText('Custom body')).toBeTruthy()
141
+ })
142
+
88
143
  it('disables every control for a read-only user', () => {
89
144
  renderForm({ canEdit: false })
90
145
  // Asserted on the fieldset, not the input: a disabled ancestor fieldset is
@@ -0,0 +1,80 @@
1
+ import { publicPathFor } from './public-path.js'
2
+
3
+ /** A related-post card the canvas and public resolve endpoint both understand. */
4
+ export interface CanvasRelatedPost {
5
+ title: string
6
+ url?: string
7
+ excerpt?: string
8
+ }
9
+
10
+ /**
11
+ * Byline for the canvas. The document's `authorName` is the post's author.
12
+ * The logged-in editor is only a fallback when that field is empty (#473).
13
+ */
14
+ export function canvasAuthorName(
15
+ authorName: string | undefined,
16
+ session?: { name?: string; email?: string },
17
+ ): string | undefined {
18
+ const fromDoc = authorName?.trim()
19
+ if (fromDoc) return fromDoc
20
+ const fallback = session?.name?.trim() || session?.email?.trim()
21
+ return fallback || undefined
22
+ }
23
+
24
+ /**
25
+ * Hand-picked related posts already expanded to objects with titles.
26
+ * Returns null when the field is missing or still a list of ids, so the
27
+ * editor can fall back to recent published siblings.
28
+ */
29
+ export function relatedPostsFromField(raw: unknown): CanvasRelatedPost[] | null {
30
+ if (!Array.isArray(raw) || raw.length === 0) return null
31
+ const cards: CanvasRelatedPost[] = []
32
+ for (const item of raw) {
33
+ if (!item || typeof item !== 'object') return null
34
+ const row = item as Record<string, unknown>
35
+ const title = typeof row.title === 'string' ? row.title.trim() : ''
36
+ if (!title) return null
37
+ const excerpt = typeof row.excerpt === 'string' ? row.excerpt : undefined
38
+ const url = typeof row.url === 'string' ? row.url : undefined
39
+ cards.push({
40
+ title,
41
+ ...(excerpt !== undefined ? { excerpt } : {}),
42
+ ...(url !== undefined ? { url } : {}),
43
+ })
44
+ }
45
+ return cards
46
+ }
47
+
48
+ interface SiblingDoc {
49
+ id: string
50
+ title?: string | null
51
+ slug?: string | null
52
+ data?: Record<string, unknown> | null
53
+ }
54
+
55
+ /**
56
+ * Recent published siblings, matching the public resolve payload: `data.title`
57
+ * wins over the denormalized column, and the URL uses the collection prefix.
58
+ */
59
+ export function relatedPostsFromSiblings(
60
+ docs: readonly SiblingDoc[],
61
+ opts: { excludeId?: string | null; urlPrefix?: string; collection: string; limit?: number },
62
+ ): CanvasRelatedPost[] {
63
+ const limit = opts.limit ?? 3
64
+ const out: CanvasRelatedPost[] = []
65
+ for (const doc of docs) {
66
+ if (opts.excludeId && doc.id === opts.excludeId) continue
67
+ const data = doc.data ?? {}
68
+ const title = (typeof data.title === 'string' && data.title.trim()) || doc.title?.trim() || ''
69
+ if (!title) continue
70
+ const slug = doc.slug ?? (typeof data.slug === 'string' ? data.slug : '')
71
+ const excerpt = typeof data.excerpt === 'string' ? data.excerpt : undefined
72
+ out.push({
73
+ title,
74
+ ...(excerpt !== undefined ? { excerpt } : {}),
75
+ ...(slug ? { url: publicPathFor(opts.urlPrefix, opts.collection, slug) } : {}),
76
+ })
77
+ if (out.length >= limit) break
78
+ }
79
+ return out
80
+ }
@@ -20,6 +20,7 @@
20
20
  * so the field-access layer doesn't strip them on write.
21
21
  */
22
22
  import { cmsApi } from './api.js'
23
+ import { relatedPostsFromField, type CanvasRelatedPost } from './post-canvas-context.js'
23
24
  import { missingCollectionFields } from './collection-schema.js'
24
25
  import { SUBMIT_BEFORE_PUBLISH_ERROR, type WorkflowStage } from './workflow-chrome.js'
25
26
  import {
@@ -75,6 +76,13 @@ export interface EditorPost {
75
76
  featuredImageUrl: string
76
77
  body: string
77
78
  category: string
79
+ /** Display byline. Empty when the document has no `authorName`. */
80
+ authorName: string
81
+ /**
82
+ * Hand-picked related posts ready for the canvas. Null when the field is
83
+ * absent or only ids, so the editor loads recent published siblings instead.
84
+ */
85
+ canvasRelatedPosts: CanvasRelatedPost[] | null
78
86
  /** Editorial tags as plain strings (stored as `[{ tag }]` rows, see {@link parseTags}). */
79
87
  tags: string[]
80
88
  status: PostStatus
@@ -297,6 +305,8 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
297
305
  featuredImageUrl: featured.url,
298
306
  body: dataStr(data, 'body'),
299
307
  category: dataStr(data, 'category'),
308
+ authorName: dataStr(data, 'authorName'),
309
+ canvasRelatedPosts: relatedPostsFromField(data.relatedPosts),
300
310
  tags: parseTags(data.tags),
301
311
  status: doc.status ?? 'DRAFT',
302
312
  workflowStage: (doc.workflowStage as WorkflowStage | undefined) ?? 'DRAFT',
@@ -491,6 +501,8 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
491
501
  featuredImageUrl: '',
492
502
  body: '',
493
503
  category: '',
504
+ authorName: '',
505
+ canvasRelatedPosts: null,
494
506
  tags: [],
495
507
  status: 'DRAFT',
496
508
  workflowStage: 'DRAFT',
@@ -90,11 +90,11 @@ export function SectionEditForm({
90
90
  <fieldset disabled={!canEdit} className="space-y-3">
91
91
  {editPostBodyField && (
92
92
  <RichTextField
93
- label="Body"
93
+ label="Post body field"
94
94
  value={postBody ?? ''}
95
95
  onChange={onPostBodyChange}
96
96
  className="[&_.ProseMirror]:min-h-[240px]"
97
- helpText="Main article copy — rendered in the canvas preview and on the live site."
97
+ helpText="Shared with post settings. Edits here change the post's Body field, which the canvas and the live site both render."
98
98
  />
99
99
  )}
100
100
 
@@ -148,11 +148,11 @@ export function SectionInspector({
148
148
  </legend>
149
149
  {editPostBodyField && (
150
150
  <RichTextField
151
- label="Body"
151
+ label="Post body field"
152
152
  value={postBody ?? ''}
153
153
  onChange={onPostBodyChange}
154
154
  className="[&_.ProseMirror]:min-h-[240px]"
155
- helpText="Main article copy — rendered in the canvas preview and on the live site."
155
+ helpText="Shared with post settings. Edits here change the post's Body field, which the canvas and the live site both render."
156
156
  />
157
157
  )}
158
158
  {contentFields.map((field) => (
@@ -125,7 +125,7 @@ function EmptyBody({ onAddSection }: { onAddSection?: () => void }) {
125
125
  </span>
126
126
  <p className="text-muted-foreground text-sm">
127
127
  {onAddSection
128
- ? 'The header is set by the template. Add body sections to build out this post. Hover sections for a light outline, click to open its editor in the section list.'
128
+ ? 'Add body sections to build out this post. Hover sections for a light outline, click to open its editor in the section list.'
129
129
  : 'This post’s template renders no body sections. Layout is design-owned — sections come from the template in code. Pick a different template in Post settings, or edit the template itself.'}
130
130
  </p>
131
131
  {onAddSection && (
@@ -21,6 +21,7 @@ function formatDate(value?: string | null): string {
21
21
  * public preview match.
22
22
  */
23
23
  export function PostHeader({ config, context }: PostHeaderProps) {
24
+ if (config.layout === 'none') return null
24
25
  const title = context.title || 'Untitled post'
25
26
  const date = config.showDate ? formatDate(context.publishDate) : ''
26
27
  const overlay =
@@ -69,32 +69,40 @@ export function PostHeaderPanel({ header, canEdit, onClose, onChange }: PostHead
69
69
  <option value="centered">Centered</option>
70
70
  <option value="left">Left-aligned</option>
71
71
  <option value="overlay">Image overlay</option>
72
+ <option value="none">None</option>
72
73
  </select>
74
+ {header.layout === 'none' && (
75
+ <p className="text-muted-foreground mt-1 text-xs">
76
+ The built-in header is hidden. Put the title in a section instead.
77
+ </p>
78
+ )}
73
79
  </div>
74
80
  </fieldset>
75
81
 
76
- <fieldset disabled={!canEdit} className="space-y-2">
77
- <legend className="text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase">
78
- Show in header
79
- </legend>
80
- {TOGGLES.map((t) => {
81
- const value = header[t.key] === true
82
- return (
83
- <div
84
- key={t.key}
85
- className="border-border text-foreground flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm"
86
- >
87
- <span>{t.label}</span>
88
- <Toggle
89
- checked={value}
90
- disabled={!canEdit}
91
- onCheckedChange={(v) => onChange({ [t.key]: v } as Partial<PostHeaderConfig>)}
92
- aria-label={t.label}
93
- />
94
- </div>
95
- )
96
- })}
97
- </fieldset>
82
+ {header.layout !== 'none' && (
83
+ <fieldset disabled={!canEdit} className="space-y-2">
84
+ <legend className="text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase">
85
+ Show in header
86
+ </legend>
87
+ {TOGGLES.map((t) => {
88
+ const value = header[t.key] === true
89
+ return (
90
+ <div
91
+ key={t.key}
92
+ className="border-border text-foreground flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm"
93
+ >
94
+ <span>{t.label}</span>
95
+ <Toggle
96
+ checked={value}
97
+ disabled={!canEdit}
98
+ onCheckedChange={(v) => onChange({ [t.key]: v } as Partial<PostHeaderConfig>)}
99
+ aria-label={t.label}
100
+ />
101
+ </div>
102
+ )
103
+ })}
104
+ </fieldset>
105
+ )}
98
106
  </div>
99
107
  </ResizableAside>
100
108
  )
@@ -55,6 +55,12 @@ import { buildEditorContentHtml } from '../../lib/editor-seo-content.js'
55
55
  import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
56
56
  import { ReflowContext } from '../page-editor/reflow-context.js'
57
57
  import type { Zoom } from '../../lib/breakpoints.js'
58
+ import { cmsApi } from '../../lib/api.js'
59
+ import {
60
+ canvasAuthorName,
61
+ relatedPostsFromSiblings,
62
+ type CanvasRelatedPost,
63
+ } from '../../lib/post-canvas-context.js'
58
64
  import { inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
59
65
  import { PostEditorCanvas } from './PostEditorCanvas.js'
60
66
  import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
@@ -333,6 +339,43 @@ export function PostSectionEditor({
333
339
  // ─── Unsaved-changes guard (client-side navigation + browser unload) ──
334
340
  useNavigationBlocker(dirty)
335
341
 
342
+ const [siblingPosts, setSiblingPosts] = useState<CanvasRelatedPost[]>([])
343
+
344
+ useEffect(() => {
345
+ if (!post?.id || post.canvasRelatedPosts !== null) {
346
+ setSiblingPosts([])
347
+ return
348
+ }
349
+ let cancelled = false
350
+ const urlPrefix = config?.collections?.[postType]?.urlPrefix
351
+ cmsApi<{
352
+ docs?: Array<{
353
+ id: string
354
+ title?: string | null
355
+ slug?: string | null
356
+ data?: Record<string, unknown> | null
357
+ }>
358
+ }>(
359
+ `/collections/${encodeURIComponent(postType)}?status=PUBLISHED&pageSize=4&sort=publishedAt&order=desc`,
360
+ )
361
+ .then((res) => {
362
+ if (cancelled) return
363
+ setSiblingPosts(
364
+ relatedPostsFromSiblings(res.data?.docs ?? [], {
365
+ excludeId: post.id,
366
+ urlPrefix,
367
+ collection: postType,
368
+ }),
369
+ )
370
+ })
371
+ .catch(() => {
372
+ if (!cancelled) setSiblingPosts([])
373
+ })
374
+ return () => {
375
+ cancelled = true
376
+ }
377
+ }, [post?.id, post?.canvasRelatedPosts, postType, config])
378
+
336
379
  const context: PostRenderContext = useMemo(
337
380
  () => ({
338
381
  title: post?.title,
@@ -341,9 +384,10 @@ export function PostSectionEditor({
341
384
  featuredImageUrl: post?.featuredImageUrl,
342
385
  publishDate: post?.publishDate ?? post?.updatedAt ?? null,
343
386
  category: post?.category,
344
- author: { name: session?.user?.name || session?.user?.email },
387
+ author: { name: canvasAuthorName(post?.authorName, session?.user) },
388
+ relatedPosts: post?.canvasRelatedPosts ?? siblingPosts,
345
389
  }),
346
- [post, session],
390
+ [post, session, siblingPosts],
347
391
  )
348
392
 
349
393
  // ─── Section mutations ───────────────────────────────────────────────