@actuate-media/cms-admin 0.40.1 → 0.42.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 (61) hide show
  1. package/dist/AdminRoot.d.ts +10 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +5 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/admin-section-renderers.test.d.ts +2 -0
  6. package/dist/__tests__/views/admin-section-renderers.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/admin-section-renderers.test.js +71 -0
  8. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -0
  9. package/dist/__tests__/views/page-section-editor.test.js +64 -0
  10. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/views/page-editor/EditorCanvas.d.ts +3 -1
  15. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  16. package/dist/views/page-editor/EditorCanvas.js +8 -7
  17. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  18. package/dist/views/page-editor/EditorTopBar.d.ts +13 -1
  19. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  20. package/dist/views/page-editor/EditorTopBar.js +11 -3
  21. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  22. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  23. package/dist/views/page-editor/PageSectionEditor.js +29 -1
  24. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  25. package/dist/views/page-editor/StructureNotice.d.ts +10 -0
  26. package/dist/views/page-editor/StructureNotice.d.ts.map +1 -0
  27. package/dist/views/page-editor/StructureNotice.js +16 -0
  28. package/dist/views/page-editor/StructureNotice.js.map +1 -0
  29. package/dist/views/page-editor/sections/SectionRenderer.d.ts +2 -0
  30. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  31. package/dist/views/page-editor/sections/SectionRenderer.js +47 -4
  32. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  33. package/dist/views/page-editor/sections/admin-renderers.d.ts +87 -0
  34. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -0
  35. package/dist/views/page-editor/sections/admin-renderers.js +46 -0
  36. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -0
  37. package/dist/views/page-editor/sections/index.d.ts +3 -1
  38. package/dist/views/page-editor/sections/index.d.ts.map +1 -1
  39. package/dist/views/page-editor/sections/index.js +2 -1
  40. package/dist/views/page-editor/sections/index.js.map +1 -1
  41. package/dist/views/post-editor/PostEditorCanvas.d.ts +3 -1
  42. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  43. package/dist/views/post-editor/PostEditorCanvas.js +3 -2
  44. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  45. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.js +29 -1
  47. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/AdminRoot.tsx +20 -1
  50. package/src/__tests__/views/admin-section-renderers.test.tsx +101 -0
  51. package/src/__tests__/views/page-section-editor.test.tsx +99 -0
  52. package/src/index.ts +11 -0
  53. package/src/views/page-editor/EditorCanvas.tsx +29 -22
  54. package/src/views/page-editor/EditorTopBar.tsx +58 -13
  55. package/src/views/page-editor/PageSectionEditor.tsx +46 -0
  56. package/src/views/page-editor/StructureNotice.tsx +27 -0
  57. package/src/views/page-editor/sections/SectionRenderer.tsx +54 -4
  58. package/src/views/page-editor/sections/admin-renderers.tsx +105 -0
  59. package/src/views/page-editor/sections/index.ts +13 -1
  60. package/src/views/post-editor/PostEditorCanvas.tsx +5 -0
  61. package/src/views/post-editor/PostSectionEditor.tsx +50 -1
@@ -5,6 +5,7 @@ import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
8
+ import { VersionHistory } from '../../components/VersionHistory.js'
8
9
  import {
9
10
  addSection,
10
11
  createSection,
@@ -32,6 +33,7 @@ import {
32
33
  } from '../../lib/preview-link.js'
33
34
  import type { SectionSettings, SectionTypeId } from './section-types.js'
34
35
  import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
36
+ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js'
35
37
  import { EditorTopBar, type SaveState } from './EditorTopBar.js'
36
38
  import { SectionsPanel } from './SectionsPanel.js'
37
39
  import { EditorCanvas } from './EditorCanvas.js'
@@ -82,12 +84,14 @@ export function PageSectionEditor({
82
84
 
83
85
  const [addOpen, setAddOpen] = useState(false)
84
86
  const [settingsOpen, setSettingsOpen] = useState(false)
87
+ const [historyOpen, setHistoryOpen] = useState(false)
85
88
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
86
89
  const [confirmPublish, setConfirmPublish] = useState(false)
87
90
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
88
91
  const [validation, setValidation] = useState<ValidationResult | null>(null)
89
92
 
90
93
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
94
+ const customRenderers = useAdminSectionRenderers()
91
95
 
92
96
  // A misconfigured `pages` collection (missing the fields the editor
93
97
  // writes) would silently drop content on save — detect it up front.
@@ -285,6 +289,23 @@ export function PageSectionEditor({
285
289
  }
286
290
  }, [page, documentId, onNavigate])
287
291
 
292
+ // After a version restore the server has already written the restored
293
+ // data — refetch so the editor reflects it instead of the stale state.
294
+ const handleRestored = useCallback(() => {
295
+ if (!page?.id) return
296
+ fetchPageForEditor(page.id)
297
+ .then((p) => {
298
+ setPage(p)
299
+ setSelectedId(null)
300
+ setDirty(false)
301
+ setSaveState('idle')
302
+ setValidation(null)
303
+ })
304
+ .catch(() => {
305
+ toast.error('Version restored — refresh the page to see it.')
306
+ })
307
+ }, [page?.id])
308
+
288
309
  // ─── Navigation with unsaved guard ───────────────────────────────────
289
310
  const guardedNavigate = useCallback(
290
311
  (path: string) => {
@@ -398,6 +419,18 @@ export function PageSectionEditor({
398
419
  new Set(page.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
399
420
  )
400
421
 
422
+ // Section types the canvas renders as placeholders — no built-in body
423
+ // component and no consumer-registered admin renderer. When present the
424
+ // editor surfaces "structure view" messaging and promotes "View on site"
425
+ // as the primary design preview.
426
+ const structuralTypes = Array.from(
427
+ new Set(
428
+ page.sections
429
+ .filter((s) => !hasCanvasRenderer(s.sectionType) && !customRenderers[s.sectionType])
430
+ .map((s) => s.sectionType),
431
+ ),
432
+ )
433
+
401
434
  return (
402
435
  <ErrorBoundary>
403
436
  <div className="bg-background flex h-full flex-col overflow-hidden">
@@ -414,9 +447,11 @@ export function PageSectionEditor({
414
447
  onBack={() => guardedNavigate('/pages')}
415
448
  onPreview={handlePreview}
416
449
  onViewOnSite={page.id ? handleViewOnSite : undefined}
450
+ onHistory={page.id ? () => setHistoryOpen(true) : undefined}
417
451
  onOpenSettings={() => setSettingsOpen(true)}
418
452
  onSaveDraft={handleSaveDraft}
419
453
  onPublish={requestPublish}
454
+ structuralTypes={structuralTypes}
420
455
  />
421
456
 
422
457
  {readOnly && (
@@ -487,6 +522,7 @@ export function PageSectionEditor({
487
522
  viewport={viewport}
488
523
  onSelect={setSelectedId}
489
524
  onAddSection={() => setAddOpen(true)}
525
+ structuralTypes={structuralTypes}
490
526
  />
491
527
  </div>
492
528
 
@@ -506,6 +542,16 @@ export function PageSectionEditor({
506
542
 
507
543
  <AddSectionModal open={addOpen} onClose={() => setAddOpen(false)} onAdd={handleAdd} />
508
544
 
545
+ {page.id && (
546
+ <VersionHistory
547
+ collectionSlug="pages"
548
+ documentId={page.id}
549
+ open={historyOpen}
550
+ onClose={() => setHistoryOpen(false)}
551
+ onRestore={handleRestored}
552
+ />
553
+ )}
554
+
509
555
  <PageSettingsModal
510
556
  open={settingsOpen}
511
557
  page={page}
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ import { Puzzle } from 'lucide-react'
4
+
5
+ /**
6
+ * Banner shown above the canvas surface when the document contains section
7
+ * types the canvas can only render as placeholders (custom / unmanaged
8
+ * types). Tells authors the canvas is a structural view — the real design
9
+ * lives on the public site behind "View on site".
10
+ */
11
+ export function StructureNotice({ structuralTypes }: { structuralTypes: string[] }) {
12
+ if (structuralTypes.length === 0) return null
13
+ const count = structuralTypes.length
14
+ return (
15
+ <div
16
+ role="note"
17
+ className="border-border bg-card text-muted-foreground mx-auto mb-4 flex w-fit max-w-full items-center gap-2 rounded-lg border px-3 py-1.5 text-xs"
18
+ >
19
+ <Puzzle className="h-3.5 w-3.5 shrink-0" aria-hidden />
20
+ <span>
21
+ <span className="text-foreground font-medium">Structure view</span> — {count} custom section{' '}
22
+ type{count > 1 ? 's' : ''} shown as placeholders. Use{' '}
23
+ <span className="text-foreground font-medium">View on site</span> for the real design.
24
+ </span>
25
+ </div>
26
+ )
27
+ }
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { Puzzle } from 'lucide-react'
4
4
  import type { PageSection } from '../../../lib/page-editor-service.js'
5
+ import { ErrorBoundary } from '../../../components/ErrorBoundary.js'
5
6
  import { getSectionType } from '../section-types.js'
7
+ import { ADMIN_RENDER_OPTIONS, useAdminSectionRenderers } from './admin-renderers.js'
6
8
  import { backgroundClass, paddingClass, type PostRenderContext } from './parts.js'
7
9
  import { HeroBannerSection } from './HeroBannerSection.js'
8
10
  import { MissionSection } from './MissionSection.js'
@@ -15,8 +17,34 @@ import { AuthorBioSection } from './AuthorBioSection.js'
15
17
  import { RelatedPostsSection } from './RelatedPostsSection.js'
16
18
  import { GallerySection } from './GallerySection.js'
17
19
 
20
+ /**
21
+ * Section types with a real canvas body component below. Anything else —
22
+ * config-declared custom types (`opal-*`), unmanaged sections, and the
23
+ * `form` embed — renders as a structural placeholder, so the editors use
24
+ * this set to tell authors the canvas shows structure, not design.
25
+ */
26
+ const CANVAS_RENDERED_TYPES: ReadonlySet<string> = new Set([
27
+ 'hero',
28
+ 'text',
29
+ 'feature',
30
+ 'stats',
31
+ 'cta',
32
+ 'article-body',
33
+ 'quote',
34
+ 'author-bio',
35
+ 'related-posts',
36
+ 'gallery',
37
+ ])
38
+
39
+ /** True when the canvas renders this type for real (not as a placeholder). */
40
+ export function hasCanvasRenderer(sectionType: string): boolean {
41
+ return CANVAS_RENDERED_TYPES.has(sectionType)
42
+ }
43
+
18
44
  /** Dispatch a section's body component by type. Shared by editor + preview. */
19
45
  function SectionBody({ section, context }: { section: PageSection; context?: PostRenderContext }) {
46
+ const customRenderers = useAdminSectionRenderers()
47
+
20
48
  switch (section.sectionType) {
21
49
  case 'hero':
22
50
  return <HeroBannerSection section={section} />
@@ -38,11 +66,33 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
38
66
  return <RelatedPostsSection section={section} context={context} />
39
67
  case 'gallery':
40
68
  return <GallerySection section={section} />
41
- default:
42
- // Custom (config-declared) types and unmanaged (externally-managed)
43
- // sections have no built-in body component. Render a neutral placeholder
44
- // so the canvas reflects their presence + order instead of a blank gap.
69
+ default: {
70
+ // Custom (config-declared) and unmanaged types: use a consumer-supplied
71
+ // canvas renderer when one is registered (see admin-renderers.tsx).
72
+ // Consumer code can throw, so a per-section error boundary falls back
73
+ // to the structural placeholder instead of taking down the editor.
74
+ const Custom = customRenderers[section.sectionType]
75
+ if (Custom) {
76
+ // The renderer contract types `settings` as an open record (matching
77
+ // the sections-react wire shape); the admin's concrete
78
+ // `SectionSettings` interface is the same object at runtime but has
79
+ // no index signature, so bridge the nominal gap here.
80
+ const wireSection = {
81
+ ...section,
82
+ settings: { ...section.settings } as Record<string, unknown>,
83
+ }
84
+ // Rendered as a component (not invoked inline) so a throw happens
85
+ // inside the boundary's subtree and is actually caught.
86
+ return (
87
+ <ErrorBoundary key={section.id} fallback={<PlaceholderSection section={section} />}>
88
+ <Custom section={wireSection} ctx={context} options={ADMIN_RENDER_OPTIONS} />
89
+ </ErrorBoundary>
90
+ )
91
+ }
92
+ // No renderer registered: a neutral placeholder keeps the canvas
93
+ // reflecting the section's presence + order instead of a blank gap.
45
94
  return <PlaceholderSection section={section} />
95
+ }
46
96
  }
47
97
  }
48
98
 
@@ -0,0 +1,105 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Pluggable canvas renderers for custom section types.
5
+ *
6
+ * By default the editor canvas renders custom (config-declared) and unmanaged
7
+ * section types as structural placeholders. Projects that want a real design
8
+ * preview inside the admin can pass renderers to `<AdminRoot
9
+ * sectionRenderers={...}>`; the canvas and the eye preview then use them in
10
+ * place of the placeholder.
11
+ *
12
+ * The renderer signature is a structural mirror of `SectionRenderer` from
13
+ * `@actuate-media/sections-react` (cms-admin depends on cms-core only, so the
14
+ * type is duplicated rather than imported). Because the shapes match, a
15
+ * consumer can hand `AdminRoot` the exact same components it registers for
16
+ * the public site:
17
+ *
18
+ * ```tsx
19
+ * import { OPAL_RENDERERS } from './sections' // SectionRendererMap
20
+ * <AdminRoot sectionRenderers={OPAL_RENDERERS} ... />
21
+ * ```
22
+ *
23
+ * Renderers run inside the admin bundle, so they must be client-safe (no
24
+ * server-only imports). Each one is wrapped in an error boundary — a throwing
25
+ * renderer falls back to the structural placeholder instead of taking down
26
+ * the editor.
27
+ */
28
+ import { createContext, useContext, type ReactNode } from 'react'
29
+
30
+ /** Structural mirror of `RenderImageProps` from `@actuate-media/sections-react`. */
31
+ export interface AdminRenderImageProps {
32
+ src: string
33
+ alt: string
34
+ width?: number | null
35
+ height?: number | null
36
+ className?: string
37
+ priority?: boolean
38
+ sizes?: string
39
+ }
40
+
41
+ /** Structural mirror of `PostContext` from `@actuate-media/sections-react`. */
42
+ export interface AdminSectionPostContext {
43
+ title?: string
44
+ excerpt?: string
45
+ body?: string
46
+ featuredImageUrl?: string
47
+ publishDate?: string | null
48
+ category?: string
49
+ author?: { name?: string; bio?: string; avatarUrl?: string }
50
+ relatedPosts?: Array<{ title: string; url?: string; excerpt?: string }>
51
+ }
52
+
53
+ /** Props passed to an admin section renderer (mirrors `SectionRendererProps`). */
54
+ export interface AdminSectionRendererProps {
55
+ section: {
56
+ id: string
57
+ sectionType: string
58
+ name?: string
59
+ visible?: boolean
60
+ content?: Record<string, unknown>
61
+ settings?: Record<string, unknown>
62
+ }
63
+ ctx?: AdminSectionPostContext
64
+ options: {
65
+ sanitizeHtml: (html: string) => string
66
+ baseUrl: string
67
+ renderImage: (props: AdminRenderImageProps) => ReactNode
68
+ }
69
+ }
70
+
71
+ /** A canvas renderer for one custom section type. */
72
+ export type AdminSectionRenderer = (props: AdminSectionRendererProps) => ReactNode
73
+
74
+ /** Section-type id → canvas renderer. */
75
+ export type AdminSectionRendererMap = Record<string, AdminSectionRenderer>
76
+
77
+ /**
78
+ * Render options passed to every admin renderer. Rich text is sanitized on
79
+ * write by cms-core, so the canvas (same trust domain as the public default)
80
+ * uses the identity sanitizer; images render as plain lazy `<img>` since the
81
+ * editor canvas has no next/image pipeline.
82
+ */
83
+ export const ADMIN_RENDER_OPTIONS: AdminSectionRendererProps['options'] = {
84
+ sanitizeHtml: (html) => html,
85
+ baseUrl: '',
86
+ renderImage: ({ src, alt, className, priority, width, height }) => (
87
+ <img
88
+ src={src}
89
+ alt={alt}
90
+ className={className}
91
+ width={width ?? undefined}
92
+ height={height ?? undefined}
93
+ loading={priority ? undefined : 'lazy'}
94
+ />
95
+ ),
96
+ }
97
+
98
+ const AdminSectionRenderersContext = createContext<AdminSectionRendererMap>({})
99
+
100
+ export const AdminSectionRenderersProvider = AdminSectionRenderersContext.Provider
101
+
102
+ /** The renderer map passed to `<AdminRoot sectionRenderers>` (empty by default). */
103
+ export function useAdminSectionRenderers(): AdminSectionRendererMap {
104
+ return useContext(AdminSectionRenderersContext)
105
+ }
@@ -1,5 +1,17 @@
1
- export { SectionRenderer } from './SectionRenderer.js'
1
+ export { SectionRenderer, hasCanvasRenderer } from './SectionRenderer.js'
2
2
  export type { SectionRendererProps } from './SectionRenderer.js'
3
+ export {
4
+ AdminSectionRenderersProvider,
5
+ useAdminSectionRenderers,
6
+ ADMIN_RENDER_OPTIONS,
7
+ } from './admin-renderers.js'
8
+ export type {
9
+ AdminSectionRenderer,
10
+ AdminSectionRendererMap,
11
+ AdminSectionRendererProps,
12
+ AdminSectionPostContext,
13
+ AdminRenderImageProps,
14
+ } from './admin-renderers.js'
3
15
  export type { PostRenderContext } from './parts.js'
4
16
  export { HeroBannerSection } from './HeroBannerSection.js'
5
17
  export { MissionSection } from './MissionSection.js'
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
4
4
  import { FileText, Plus } from 'lucide-react'
5
5
  import type { PageSection, PostHeaderConfig } from '../../lib/post-editor-service.js'
6
6
  import { SectionRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
7
+ import { StructureNotice } from '../page-editor/StructureNotice.js'
7
8
  import { getViewport, type ViewportId } from '../page-editor/viewports.js'
8
9
  import { PostHeader } from './PostHeader.js'
9
10
 
@@ -15,6 +16,8 @@ interface PostEditorCanvasProps {
15
16
  viewport: ViewportId
16
17
  onSelect: (id: string) => void
17
18
  onAddSection: () => void
19
+ /** Section types rendered as placeholders — shows the "Structure view" notice. */
20
+ structuralTypes?: string[]
18
21
  }
19
22
 
20
23
  /**
@@ -31,6 +34,7 @@ export function PostEditorCanvas({
31
34
  viewport,
32
35
  onSelect,
33
36
  onAddSection,
37
+ structuralTypes = [],
34
38
  }: PostEditorCanvasProps) {
35
39
  const vp = getViewport(viewport)
36
40
  const containerRef = useRef<HTMLDivElement>(null)
@@ -70,6 +74,7 @@ export function PostEditorCanvas({
70
74
 
71
75
  return (
72
76
  <div ref={containerRef} className="bg-muted h-full overflow-auto p-6">
77
+ <StructureNotice structuralTypes={structuralTypes} />
73
78
  <div
74
79
  className="mx-auto"
75
80
  style={{ width: vp.width * scale, height: surfaceHeight ?? undefined }}
@@ -5,6 +5,7 @@ import { AlertTriangle, Loader2 } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
8
+ import { VersionHistory } from '../../components/VersionHistory.js'
8
9
  import {
9
10
  addSection,
10
11
  createSection,
@@ -33,7 +34,11 @@ import {
33
34
  resolveSiteUrl,
34
35
  } from '../../lib/preview-link.js'
35
36
  import type { SectionSettings, SectionTypeId } from '../page-editor/section-types.js'
36
- import type { PostRenderContext } from '../page-editor/sections/index.js'
37
+ import {
38
+ hasCanvasRenderer,
39
+ useAdminSectionRenderers,
40
+ type PostRenderContext,
41
+ } from '../page-editor/sections/index.js'
37
42
  import { DEFAULT_VIEWPORT, type ViewportId } from '../page-editor/viewports.js'
38
43
  import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
39
44
  import { SectionsPanel } from '../page-editor/SectionsPanel.js'
@@ -104,12 +109,14 @@ export function PostSectionEditor({
104
109
 
105
110
  const [addOpen, setAddOpen] = useState(false)
106
111
  const [fieldsOpen, setFieldsOpen] = useState(false)
112
+ const [historyOpen, setHistoryOpen] = useState(false)
107
113
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
108
114
  const [confirmPublish, setConfirmPublish] = useState(false)
109
115
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
110
116
  const [validation, setValidation] = useState<ValidationResult | null>(null)
111
117
 
112
118
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
119
+ const customRenderers = useAdminSectionRenderers()
113
120
 
114
121
  const typeLabel = useMemo(() => {
115
122
  const col = config?.collections?.[postType]
@@ -302,6 +309,23 @@ export function PostSectionEditor({
302
309
  }
303
310
  }, [post, documentId, postType, onNavigate])
304
311
 
312
+ // After a version restore the server has already written the restored
313
+ // data — refetch so the editor reflects it instead of the stale state.
314
+ const handleRestored = useCallback(() => {
315
+ if (!post?.id) return
316
+ fetchPostForEditor(postType, post.id)
317
+ .then((p) => {
318
+ setPost(p)
319
+ setSelectedId(null)
320
+ setDirty(false)
321
+ setSaveState('idle')
322
+ setValidation(null)
323
+ })
324
+ .catch(() => {
325
+ toast.error('Version restored — refresh the page to see it.')
326
+ })
327
+ }, [postType, post?.id])
328
+
305
329
  const guardedNavigate = useCallback(
306
330
  (path: string) => {
307
331
  if (dirty) setLeaveTarget(path)
@@ -400,6 +424,18 @@ export function PostSectionEditor({
400
424
 
401
425
  const selected = post.sections.find((s) => s.id === selectedId) ?? null
402
426
 
427
+ // Section types the canvas renders as placeholders — no built-in body
428
+ // component and no consumer-registered admin renderer. When present the
429
+ // editor surfaces "structure view" messaging and promotes "View on site"
430
+ // as the primary design preview.
431
+ const structuralTypes = Array.from(
432
+ new Set(
433
+ post.sections
434
+ .filter((s) => !hasCanvasRenderer(s.sectionType) && !customRenderers[s.sectionType])
435
+ .map((s) => s.sectionType),
436
+ ),
437
+ )
438
+
403
439
  return (
404
440
  <ErrorBoundary>
405
441
  <div className="bg-background flex h-full flex-col overflow-hidden">
@@ -417,9 +453,11 @@ export function PostSectionEditor({
417
453
  onBack={() => guardedNavigate('/posts')}
418
454
  onPreview={handlePreview}
419
455
  onViewOnSite={post.id ? handleViewOnSite : undefined}
456
+ onHistory={post.id ? () => setHistoryOpen(true) : undefined}
420
457
  onOpenSettings={() => setFieldsOpen(true)}
421
458
  onSaveDraft={handleSaveDraft}
422
459
  onPublish={requestPublish}
460
+ structuralTypes={structuralTypes}
423
461
  />
424
462
 
425
463
  {validation && (
@@ -452,6 +490,7 @@ export function PostSectionEditor({
452
490
  viewport={viewport}
453
491
  onSelect={setSelectedId}
454
492
  onAddSection={() => setAddOpen(true)}
493
+ structuralTypes={structuralTypes}
455
494
  />
456
495
  </div>
457
496
 
@@ -476,6 +515,16 @@ export function PostSectionEditor({
476
515
  onAdd={handleAdd}
477
516
  />
478
517
 
518
+ {post.id && (
519
+ <VersionHistory
520
+ collectionSlug={postType}
521
+ documentId={post.id}
522
+ open={historyOpen}
523
+ onClose={() => setHistoryOpen(false)}
524
+ onRestore={handleRestored}
525
+ />
526
+ )}
527
+
479
528
  <PostFieldsModal
480
529
  open={fieldsOpen}
481
530
  post={post}