@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.
- package/dist/AdminRoot.d.ts +10 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +5 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/admin-section-renderers.test.d.ts +2 -0
- package/dist/__tests__/views/admin-section-renderers.test.d.ts.map +1 -0
- package/dist/__tests__/views/admin-section-renderers.test.js +71 -0
- package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +14 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/portal-container.test.d.ts +2 -0
- package/dist/__tests__/views/portal-container.test.d.ts.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +42 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -0
- package/dist/components/LocaleSwitcher.d.ts.map +1 -1
- package/dist/components/LocaleSwitcher.js +2 -1
- package/dist/components/LocaleSwitcher.js.map +1 -1
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +2 -1
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/forms/Drawer.d.ts.map +1 -1
- package/dist/components/forms/Drawer.js +2 -1
- package/dist/components/forms/Drawer.js.map +1 -1
- package/dist/components/seo/Drawer.d.ts.map +1 -1
- package/dist/components/seo/Drawer.js +2 -1
- package/dist/components/seo/Drawer.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +2 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -1
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/portal-container.d.ts +12 -0
- package/dist/lib/portal-container.d.ts.map +1 -0
- package/dist/lib/portal-container.js +16 -0
- package/dist/lib/portal-container.js.map +1 -0
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +3 -2
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +2 -1
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +2 -1
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +4 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/BlockPicker.d.ts.map +1 -1
- package/dist/views/page-builder/BlockPicker.js +2 -1
- package/dist/views/page-builder/BlockPicker.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +9 -5
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +2 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +25 -4
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/page-editor/sections/admin-renderers.d.ts +87 -0
- package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -0
- package/dist/views/page-editor/sections/admin-renderers.js +46 -0
- package/dist/views/page-editor/sections/admin-renderers.js.map +1 -0
- package/dist/views/page-editor/sections/index.d.ts +2 -0
- package/dist/views/page-editor/sections/index.d.ts.map +1 -1
- package/dist/views/page-editor/sections/index.js +1 -0
- package/dist/views/page-editor/sections/index.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +9 -5
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -1
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +20 -1
- package/src/__tests__/views/admin-section-renderers.test.tsx +101 -0
- package/src/__tests__/views/page-section-editor.test.tsx +29 -0
- package/src/__tests__/views/portal-container.test.tsx +56 -0
- package/src/components/LocaleSwitcher.tsx +2 -1
- package/src/components/SEOPanel.tsx +2 -1
- package/src/components/forms/Drawer.tsx +2 -1
- package/src/components/seo/Drawer.tsx +2 -1
- package/src/index.ts +11 -0
- package/src/layout/Header.tsx +2 -1
- package/src/layout/Sidebar.tsx +2 -1
- package/src/lib/portal-container.ts +14 -0
- package/src/views/ApiKeys.tsx +3 -2
- package/src/views/Pages/PagesListView.tsx +2 -1
- package/src/views/Redirects.tsx +2 -1
- package/src/views/Users.tsx +4 -3
- package/src/views/page-builder/BlockPicker.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +9 -5
- package/src/views/page-editor/SectionsPanel.tsx +2 -1
- package/src/views/page-editor/sections/SectionRenderer.tsx +30 -4
- package/src/views/page-editor/sections/admin-renderers.tsx +105 -0
- package/src/views/page-editor/sections/index.ts +12 -0
- package/src/views/post-editor/PostSectionEditor.tsx +13 -5
- 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'
|
package/src/layout/Header.tsx
CHANGED
|
@@ -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"
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -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}
|
package/src/views/Redirects.tsx
CHANGED
|
@@ -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">
|
package/src/views/Users.tsx
CHANGED
|
@@ -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
|
|
422
|
-
//
|
|
423
|
-
//
|
|
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
|
|
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)
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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 {
|
|
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
|
|
423
|
-
//
|
|
424
|
-
//
|
|
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
|
|
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">
|