@actuate-media/cms-admin 0.41.0 → 0.42.1

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 (102) 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 +14 -0
  10. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  11. package/dist/__tests__/views/portal-container.test.d.ts +2 -0
  12. package/dist/__tests__/views/portal-container.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/portal-container.test.js +42 -0
  14. package/dist/__tests__/views/portal-container.test.js.map +1 -0
  15. package/dist/components/LocaleSwitcher.d.ts.map +1 -1
  16. package/dist/components/LocaleSwitcher.js +2 -1
  17. package/dist/components/LocaleSwitcher.js.map +1 -1
  18. package/dist/components/SEOPanel.d.ts.map +1 -1
  19. package/dist/components/SEOPanel.js +2 -1
  20. package/dist/components/SEOPanel.js.map +1 -1
  21. package/dist/components/forms/Drawer.d.ts.map +1 -1
  22. package/dist/components/forms/Drawer.js +2 -1
  23. package/dist/components/forms/Drawer.js.map +1 -1
  24. package/dist/components/seo/Drawer.d.ts.map +1 -1
  25. package/dist/components/seo/Drawer.js +2 -1
  26. package/dist/components/seo/Drawer.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/layout/Header.d.ts.map +1 -1
  31. package/dist/layout/Header.js +2 -1
  32. package/dist/layout/Header.js.map +1 -1
  33. package/dist/layout/Sidebar.d.ts.map +1 -1
  34. package/dist/layout/Sidebar.js +2 -1
  35. package/dist/layout/Sidebar.js.map +1 -1
  36. package/dist/lib/portal-container.d.ts +12 -0
  37. package/dist/lib/portal-container.d.ts.map +1 -0
  38. package/dist/lib/portal-container.js +16 -0
  39. package/dist/lib/portal-container.js.map +1 -0
  40. package/dist/views/ApiKeys.d.ts.map +1 -1
  41. package/dist/views/ApiKeys.js +3 -2
  42. package/dist/views/ApiKeys.js.map +1 -1
  43. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  44. package/dist/views/Pages/PagesListView.js +2 -1
  45. package/dist/views/Pages/PagesListView.js.map +1 -1
  46. package/dist/views/Redirects.d.ts.map +1 -1
  47. package/dist/views/Redirects.js +2 -1
  48. package/dist/views/Redirects.js.map +1 -1
  49. package/dist/views/Users.d.ts.map +1 -1
  50. package/dist/views/Users.js +4 -3
  51. package/dist/views/Users.js.map +1 -1
  52. package/dist/views/page-builder/BlockPicker.d.ts.map +1 -1
  53. package/dist/views/page-builder/BlockPicker.js +2 -1
  54. package/dist/views/page-builder/BlockPicker.js.map +1 -1
  55. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  56. package/dist/views/page-editor/PageSectionEditor.js +9 -5
  57. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  58. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  59. package/dist/views/page-editor/SectionsPanel.js +2 -1
  60. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  61. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  62. package/dist/views/page-editor/sections/SectionRenderer.js +25 -4
  63. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  64. package/dist/views/page-editor/sections/admin-renderers.d.ts +87 -0
  65. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -0
  66. package/dist/views/page-editor/sections/admin-renderers.js +46 -0
  67. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -0
  68. package/dist/views/page-editor/sections/index.d.ts +2 -0
  69. package/dist/views/page-editor/sections/index.d.ts.map +1 -1
  70. package/dist/views/page-editor/sections/index.js +1 -0
  71. package/dist/views/page-editor/sections/index.js.map +1 -1
  72. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  73. package/dist/views/post-editor/PostSectionEditor.js +9 -5
  74. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  75. package/dist/views/settings/components.d.ts.map +1 -1
  76. package/dist/views/settings/components.js +2 -1
  77. package/dist/views/settings/components.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/AdminRoot.tsx +20 -1
  80. package/src/__tests__/views/admin-section-renderers.test.tsx +101 -0
  81. package/src/__tests__/views/page-section-editor.test.tsx +29 -0
  82. package/src/__tests__/views/portal-container.test.tsx +56 -0
  83. package/src/components/LocaleSwitcher.tsx +2 -1
  84. package/src/components/SEOPanel.tsx +2 -1
  85. package/src/components/forms/Drawer.tsx +2 -1
  86. package/src/components/seo/Drawer.tsx +2 -1
  87. package/src/index.ts +11 -0
  88. package/src/layout/Header.tsx +2 -1
  89. package/src/layout/Sidebar.tsx +2 -1
  90. package/src/lib/portal-container.ts +14 -0
  91. package/src/views/ApiKeys.tsx +3 -2
  92. package/src/views/Pages/PagesListView.tsx +2 -1
  93. package/src/views/Redirects.tsx +2 -1
  94. package/src/views/Users.tsx +4 -3
  95. package/src/views/page-builder/BlockPicker.tsx +2 -1
  96. package/src/views/page-editor/PageSectionEditor.tsx +9 -5
  97. package/src/views/page-editor/SectionsPanel.tsx +2 -1
  98. package/src/views/page-editor/sections/SectionRenderer.tsx +30 -4
  99. package/src/views/page-editor/sections/admin-renderers.tsx +105 -0
  100. package/src/views/page-editor/sections/index.ts +12 -0
  101. package/src/views/post-editor/PostSectionEditor.tsx +13 -5
  102. package/src/views/settings/components.tsx +2 -1
@@ -12,6 +12,7 @@ vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
12
12
  })
13
13
 
14
14
  const { PageSectionEditor } = await import('../../views/page-editor/PageSectionEditor.js')
15
+ const { AdminSectionRenderersProvider } = await import('../../views/page-editor/sections/index.js')
15
16
 
16
17
  const validConfig = {
17
18
  collections: {
@@ -121,6 +122,34 @@ describe('PageSectionEditor structure mode', () => {
121
122
  expect(screen.getByRole('button', { name: /Preview structure/ })).toBeTruthy()
122
123
  })
123
124
 
125
+ it('stays out of structure mode when a custom renderer is registered for the type', async () => {
126
+ fetchPageForEditor.mockResolvedValue(
127
+ loadedPage([
128
+ { id: 's1', sectionType: 'hero', name: 'Hero' },
129
+ { id: 's2', sectionType: 'opal-flip-cards', name: 'Flip Cards' },
130
+ ]),
131
+ )
132
+ render(
133
+ <AdminSectionRenderersProvider
134
+ value={{ 'opal-flip-cards': () => <div data-testid="opal-canvas" /> }}
135
+ >
136
+ <PageSectionEditor
137
+ documentId="p1"
138
+ config={validConfig}
139
+ onNavigate={() => {}}
140
+ session={{}}
141
+ />
142
+ </AdminSectionRenderersProvider>,
143
+ )
144
+
145
+ await screen.findByText('Sections')
146
+ // The registered renderer draws the section, so the canvas is no longer
147
+ // structural for this page.
148
+ expect(screen.getByTestId('opal-canvas')).toBeTruthy()
149
+ expect(screen.queryByText(/Structure view/)).toBeNull()
150
+ expect(screen.getByRole('button', { name: 'View draft on site' })).toBeTruthy()
151
+ })
152
+
124
153
  it('exposes a version history button for saved pages', async () => {
125
154
  fetchPageForEditor.mockResolvedValue(
126
155
  loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
@@ -0,0 +1,56 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import { ConfirmDangerousSettingDialog } from '../../views/settings/components.js'
5
+ import { adminPortalContainer } from '../../lib/portal-container.js'
6
+
7
+ /**
8
+ * Regression: theme tokens only exist under `.actuate-admin` (theme.css), so
9
+ * Radix portals must mount INSIDE that scope. Portaling to document.body
10
+ * (Radix's default) produced unstyled overlays — transparent cards, colorless
11
+ * destructive buttons, broken dark mode.
12
+ */
13
+
14
+ afterEach(cleanup)
15
+
16
+ function renderInAdminRoot(ui: React.ReactElement) {
17
+ const root = document.createElement('div')
18
+ root.className = 'actuate-admin'
19
+ document.body.appendChild(root)
20
+ return render(ui, { container: root })
21
+ }
22
+
23
+ describe('adminPortalContainer', () => {
24
+ it('resolves the admin root element', () => {
25
+ const root = document.createElement('div')
26
+ root.className = 'actuate-admin'
27
+ document.body.appendChild(root)
28
+ expect(adminPortalContainer()).toBe(root)
29
+ root.remove()
30
+ })
31
+
32
+ it('returns undefined when no admin root is mounted', () => {
33
+ document.querySelectorAll('.actuate-admin').forEach((el) => el.remove())
34
+ expect(adminPortalContainer()).toBeUndefined()
35
+ })
36
+ })
37
+
38
+ describe('portaled overlays mount inside the admin theme scope', () => {
39
+ it('renders the dangerous-setting confirm dialog within .actuate-admin', async () => {
40
+ renderInAdminRoot(
41
+ <ConfirmDangerousSettingDialog
42
+ open
43
+ title="Block this production site from search engines?"
44
+ description="This may prevent search engines from indexing your production site."
45
+ confirmLabel="Enable No Index"
46
+ onConfirm={() => {}}
47
+ onCancel={() => {}}
48
+ />,
49
+ )
50
+
51
+ const title = await screen.findByText(/Block this production site/)
52
+ // The dialog content must live inside the token scope, otherwise
53
+ // bg-card / text-warning / bg-destructive resolve to nothing.
54
+ expect(title.closest('.actuate-admin')).not.toBeNull()
55
+ })
56
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Globe, Check, ChevronDown } from 'lucide-react'
4
4
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+ import { adminPortalContainer } from '../lib/portal-container.js'
5
6
  import { useLocale } from './LocaleProvider.js'
6
7
 
7
8
  export function LocaleSwitcher() {
@@ -26,7 +27,7 @@ export function LocaleSwitcher() {
26
27
  </button>
27
28
  </DropdownMenu.Trigger>
28
29
 
29
- <DropdownMenu.Portal>
30
+ <DropdownMenu.Portal container={adminPortalContainer()}>
30
31
  <DropdownMenu.Content
31
32
  className="z-50 min-w-[160px] rounded-lg border border-[var(--border)] bg-[var(--popover)] p-1 text-[var(--popover-foreground)] shadow-lg"
32
33
  align="end"
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Select from '@radix-ui/react-select'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import { useState, useMemo } from 'react'
5
6
  import {
6
7
  Search,
@@ -801,7 +802,7 @@ export function SEOPanel({
801
802
  <ChevronDown className="text-muted-foreground h-4 w-4" />
802
803
  </Select.Icon>
803
804
  </Select.Trigger>
804
- <Select.Portal>
805
+ <Select.Portal container={adminPortalContainer()}>
805
806
  <Select.Content className="border-border bg-card z-50 overflow-hidden rounded-lg border shadow-md">
806
807
  <Select.Viewport className="p-1">
807
808
  {ROBOTS_POLICY_OPTIONS.map((option) => (
@@ -5,6 +5,7 @@
5
5
  * token-only styling. Shared by the Forms schema / entry-detail drawers.
6
6
  */
7
7
  import * as Dialog from '@radix-ui/react-dialog'
8
+ import { adminPortalContainer } from '../../lib/portal-container.js'
8
9
  import { X } from 'lucide-react'
9
10
  import type { ReactNode } from 'react'
10
11
  import { btnGhostIcon } from './primitives.js'
@@ -31,7 +32,7 @@ export function Drawer({
31
32
  }) {
32
33
  return (
33
34
  <Dialog.Root open={open} onOpenChange={onOpenChange}>
34
- <Dialog.Portal>
35
+ <Dialog.Portal container={adminPortalContainer()}>
35
36
  <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
36
37
  <Dialog.Content
37
38
  {...(description ? {} : { 'aria-describedby': undefined })}
@@ -5,6 +5,7 @@
5
5
  * token-only styling. Shared by the SEO editor / fix / copilot drawers.
6
6
  */
7
7
  import * as Dialog from '@radix-ui/react-dialog'
8
+ import { adminPortalContainer } from '../../lib/portal-container.js'
8
9
  import { X } from 'lucide-react'
9
10
  import type { ReactNode } from 'react'
10
11
  import { btnGhostIcon } from './primitives.js'
@@ -28,7 +29,7 @@ export function Drawer({
28
29
  }) {
29
30
  return (
30
31
  <Dialog.Root open={open} onOpenChange={onOpenChange}>
31
- <Dialog.Portal>
32
+ <Dialog.Portal container={adminPortalContainer()}>
32
33
  <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
33
34
  <Dialog.Content
34
35
  // Radix auto-associates `aria-describedby` with the Dialog.Description
package/src/index.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  export { AdminRoot } from './AdminRoot.js'
2
2
  export type { AdminRootProps } from './AdminRoot.js'
3
3
 
4
+ // Pluggable canvas renderers for custom section types (see `AdminRoot`'s
5
+ // `sectionRenderers` prop). The types structurally mirror
6
+ // `@actuate-media/sections-react`, so public-site renderers are assignable.
7
+ export type {
8
+ AdminSectionRenderer,
9
+ AdminSectionRendererMap,
10
+ AdminSectionRendererProps,
11
+ AdminSectionPostContext,
12
+ AdminRenderImageProps,
13
+ } from './views/page-editor/sections/index.js'
14
+
4
15
  export { Layout } from './layout/Layout.js'
5
16
  export type { LayoutProps } from './layout/Layout.js'
6
17
  export { Sidebar } from './layout/Sidebar.js'
@@ -3,6 +3,7 @@
3
3
  import { useState, type ReactNode } from 'react'
4
4
  import { Search, User, ChevronDown, Menu, Sun, Moon } from 'lucide-react'
5
5
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
6
+ import { adminPortalContainer } from '../lib/portal-container.js'
6
7
  import { CommandPalette } from '../components/CommandPalette.js'
7
8
  import { NotificationBell } from '../components/NotificationBell.js'
8
9
  import { useTheme } from '../components/ThemeProvider.js'
@@ -132,7 +133,7 @@ export function Header({
132
133
  </button>
133
134
  </DropdownMenu.Trigger>
134
135
 
135
- <DropdownMenu.Portal>
136
+ <DropdownMenu.Portal container={adminPortalContainer()}>
136
137
  <DropdownMenu.Content
137
138
  className="bg-popover text-popover-foreground border-border z-50 min-w-[200px] rounded-lg border p-1 shadow-lg"
138
139
  align="end"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState, type ReactNode } from 'react'
4
4
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+ import { adminPortalContainer } from '../lib/portal-container.js'
5
6
  import { useTheme } from '../components/ThemeProvider.js'
6
7
  import { fetchFormsSidebarCounts, type FormsSidebarCount } from '../lib/forms-service.js'
7
8
  import { onFormsChanged } from '../lib/forms-events.js'
@@ -664,7 +665,7 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
664
665
  </DropdownMenu.Trigger>
665
666
  </div>
666
667
 
667
- <DropdownMenu.Portal>
668
+ <DropdownMenu.Portal container={adminPortalContainer()}>
668
669
  <DropdownMenu.Content
669
670
  side="right"
670
671
  align="start"
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Admin theme tokens are scoped under `.actuate-admin` (styles/theme.css),
3
+ * and dark mode / accent / density are applied as classes-attributes on that
4
+ * same root element. Radix portals default to `document.body`, which sits
5
+ * OUTSIDE that scope — portaled overlays would render with no theme tokens
6
+ * at all (transparent cards, colorless buttons) and ignore dark mode.
7
+ *
8
+ * Every Radix `*.Portal` in the admin must pass this as its `container`.
9
+ * Returns `undefined` during SSR (portals only materialize client-side).
10
+ */
11
+ export function adminPortalContainer(): HTMLElement | undefined {
12
+ if (typeof document === 'undefined') return undefined
13
+ return document.querySelector<HTMLElement>('.actuate-admin') ?? undefined
14
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import {
5
6
  Copy,
6
7
  KeyRound,
@@ -345,7 +346,7 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
345
346
 
346
347
  return (
347
348
  <Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
348
- <Dialog.Portal>
349
+ <Dialog.Portal container={adminPortalContainer()}>
349
350
  <Dialog.Overlay className="fixed inset-0 z-40 bg-black/40" />
350
351
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-xl">
351
352
  <Dialog.Title className="text-card-foreground mb-4 flex items-center gap-2 text-lg font-medium">
@@ -493,7 +494,7 @@ function RevealKeyDialog({
493
494
  const [shown, setShown] = useState(false)
494
495
  return (
495
496
  <Dialog.Root open onOpenChange={(o) => !o && onClose()}>
496
- <Dialog.Portal>
497
+ <Dialog.Portal container={adminPortalContainer()}>
497
498
  <Dialog.Overlay className="fixed inset-0 z-40 bg-black/40" />
498
499
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-xl">
499
500
  <Dialog.Title className="text-card-foreground mb-2 flex items-center gap-2 text-lg font-medium">
@@ -14,6 +14,7 @@ import {
14
14
  } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
16
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
17
+ import { adminPortalContainer } from '../../lib/portal-container.js'
17
18
  import {
18
19
  applyPagesQuery,
19
20
  bulkChangePageTags,
@@ -738,7 +739,7 @@ function TagMenu({
738
739
  <ChevronDown className="h-3 w-3" aria-hidden />
739
740
  </button>
740
741
  </DropdownMenu.Trigger>
741
- <DropdownMenu.Portal>
742
+ <DropdownMenu.Portal container={adminPortalContainer()}>
742
743
  <DropdownMenu.Content
743
744
  align="start"
744
745
  sideOffset={4}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import {
5
6
  Pencil,
6
7
  Plus,
@@ -278,7 +279,7 @@ export function Redirects({ onNavigate }: RedirectsProps) {
278
279
  )}
279
280
 
280
281
  <Dialog.Root open={showAddDialog} onOpenChange={setShowAddDialog}>
281
- <Dialog.Portal>
282
+ <Dialog.Portal container={adminPortalContainer()}>
282
283
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
283
284
  <Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
284
285
  <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import { adminPortalContainer } from '../lib/portal-container.js'
4
5
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
6
  import {
6
7
  Users as UsersIcon,
@@ -357,7 +358,7 @@ function RoleDropdown({
357
358
  <ChevronDown size={12} aria-hidden />
358
359
  </button>
359
360
  </DropdownMenu.Trigger>
360
- <DropdownMenu.Portal>
361
+ <DropdownMenu.Portal container={adminPortalContainer()}>
361
362
  <DropdownMenu.Content
362
363
  align="start"
363
364
  sideOffset={4}
@@ -483,7 +484,7 @@ function InviteModal({
483
484
  if (!v) reset()
484
485
  }}
485
486
  >
486
- <Dialog.Portal>
487
+ <Dialog.Portal container={adminPortalContainer()}>
487
488
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" />
488
489
  <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 max-h-[90vh] w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border p-6 shadow-lg focus:outline-none">
489
490
  <div className="mb-4 flex items-center justify-between">
@@ -1187,7 +1188,7 @@ function RowActions({
1187
1188
  <MoreHorizontal size={16} aria-hidden />
1188
1189
  </button>
1189
1190
  </DropdownMenu.Trigger>
1190
- <DropdownMenu.Portal>
1191
+ <DropdownMenu.Portal container={adminPortalContainer()}>
1191
1192
  <DropdownMenu.Content
1192
1193
  align="end"
1193
1194
  sideOffset={4}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
4
4
  import * as Dialog from '@radix-ui/react-dialog'
5
+ import { adminPortalContainer } from '../../lib/portal-container.js'
5
6
  import {
6
7
  Star,
7
8
  Type,
@@ -126,7 +127,7 @@ export function BlockPicker({ open, onClose, onSelect }: BlockPickerProps) {
126
127
 
127
128
  return (
128
129
  <Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
129
- <Dialog.Portal>
130
+ <Dialog.Portal container={adminPortalContainer()}>
130
131
  <Dialog.Overlay className="motion-safe:animate-in motion-safe:fade-in-0 fixed inset-0 z-50 bg-black/50" />
131
132
  <Dialog.Content
132
133
  className="bg-card border-border fixed top-1/2 left-1/2 z-50 flex max-h-[80vh] w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border shadow-2xl"
@@ -33,7 +33,7 @@ import {
33
33
  } from '../../lib/preview-link.js'
34
34
  import type { SectionSettings, SectionTypeId } from './section-types.js'
35
35
  import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
36
- import { hasCanvasRenderer } from './sections/index.js'
36
+ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js'
37
37
  import { EditorTopBar, type SaveState } from './EditorTopBar.js'
38
38
  import { SectionsPanel } from './SectionsPanel.js'
39
39
  import { EditorCanvas } from './EditorCanvas.js'
@@ -91,6 +91,7 @@ export function PageSectionEditor({
91
91
  const [validation, setValidation] = useState<ValidationResult | null>(null)
92
92
 
93
93
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
94
+ const customRenderers = useAdminSectionRenderers()
94
95
 
95
96
  // A misconfigured `pages` collection (missing the fields the editor
96
97
  // writes) would silently drop content on save — detect it up front.
@@ -418,12 +419,15 @@ export function PageSectionEditor({
418
419
  new Set(page.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
419
420
  )
420
421
 
421
- // Section types the canvas renders as placeholders (custom + unmanaged).
422
- // When present the editor surfaces "structure view" messaging and promotes
423
- // "View on site" as the primary design preview.
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.
424
426
  const structuralTypes = Array.from(
425
427
  new Set(
426
- page.sections.filter((s) => !hasCanvasRenderer(s.sectionType)).map((s) => s.sectionType),
428
+ page.sections
429
+ .filter((s) => !hasCanvasRenderer(s.sectionType) && !customRenderers[s.sectionType])
430
+ .map((s) => s.sectionType),
427
431
  ),
428
432
  )
429
433
 
@@ -28,6 +28,7 @@ import {
28
28
  } from '@dnd-kit/sortable'
29
29
  import { CSS } from '@dnd-kit/utilities'
30
30
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
31
+ import { adminPortalContainer } from '../../lib/portal-container.js'
31
32
  import type { PageSection } from '../../lib/page-editor-service.js'
32
33
  import { sectionIconFor } from './section-icons.js'
33
34
 
@@ -238,7 +239,7 @@ function SortableSectionItem({
238
239
  <MoreVertical className="h-4 w-4" aria-hidden />
239
240
  </button>
240
241
  </DropdownMenu.Trigger>
241
- <DropdownMenu.Portal>
242
+ <DropdownMenu.Portal container={adminPortalContainer()}>
242
243
  <DropdownMenu.Content
243
244
  align="end"
244
245
  sideOffset={4}
@@ -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'
@@ -41,6 +43,8 @@ export function hasCanvasRenderer(sectionType: string): boolean {
41
43
 
42
44
  /** Dispatch a section's body component by type. Shared by editor + preview. */
43
45
  function SectionBody({ section, context }: { section: PageSection; context?: PostRenderContext }) {
46
+ const customRenderers = useAdminSectionRenderers()
47
+
44
48
  switch (section.sectionType) {
45
49
  case 'hero':
46
50
  return <HeroBannerSection section={section} />
@@ -62,11 +66,33 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
62
66
  return <RelatedPostsSection section={section} context={context} />
63
67
  case 'gallery':
64
68
  return <GallerySection section={section} />
65
- default:
66
- // Custom (config-declared) types and unmanaged (externally-managed)
67
- // sections have no built-in body component. Render a neutral placeholder
68
- // 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.
69
94
  return <PlaceholderSection section={section} />
95
+ }
70
96
  }
71
97
  }
72
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
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'
@@ -34,7 +34,11 @@ import {
34
34
  resolveSiteUrl,
35
35
  } from '../../lib/preview-link.js'
36
36
  import type { SectionSettings, SectionTypeId } from '../page-editor/section-types.js'
37
- import { hasCanvasRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
37
+ import {
38
+ hasCanvasRenderer,
39
+ useAdminSectionRenderers,
40
+ type PostRenderContext,
41
+ } from '../page-editor/sections/index.js'
38
42
  import { DEFAULT_VIEWPORT, type ViewportId } from '../page-editor/viewports.js'
39
43
  import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
40
44
  import { SectionsPanel } from '../page-editor/SectionsPanel.js'
@@ -112,6 +116,7 @@ export function PostSectionEditor({
112
116
  const [validation, setValidation] = useState<ValidationResult | null>(null)
113
117
 
114
118
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
119
+ const customRenderers = useAdminSectionRenderers()
115
120
 
116
121
  const typeLabel = useMemo(() => {
117
122
  const col = config?.collections?.[postType]
@@ -419,12 +424,15 @@ export function PostSectionEditor({
419
424
 
420
425
  const selected = post.sections.find((s) => s.id === selectedId) ?? null
421
426
 
422
- // Section types the canvas renders as placeholders (custom + unmanaged).
423
- // When present the editor surfaces "structure view" messaging and promotes
424
- // "View on site" as the primary design preview.
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.
425
431
  const structuralTypes = Array.from(
426
432
  new Set(
427
- post.sections.filter((s) => !hasCanvasRenderer(s.sectionType)).map((s) => s.sectionType),
433
+ post.sections
434
+ .filter((s) => !hasCanvasRenderer(s.sectionType) && !customRenderers[s.sectionType])
435
+ .map((s) => s.sectionType),
428
436
  ),
429
437
  )
430
438
 
@@ -3,6 +3,7 @@
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
4
  import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
5
5
  import { useId, type ReactNode } from 'react'
6
+ import { adminPortalContainer } from '../../lib/portal-container.js'
6
7
  import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
7
8
 
8
9
  /**
@@ -290,7 +291,7 @@ export function ConfirmDangerousSettingDialog({
290
291
  }) {
291
292
  return (
292
293
  <Dialog.Root open={open} onOpenChange={(o) => !o && onCancel()}>
293
- <Dialog.Portal>
294
+ <Dialog.Portal container={adminPortalContainer()}>
294
295
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
295
296
  <Dialog.Content className="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg">
296
297
  <div className="mb-3 flex items-center gap-2">