@actuate-media/cms-admin 0.38.0 → 0.39.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 (91) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +8 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts +2 -0
  5. package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/breadcrumbs-is-id.test.js +28 -0
  7. package/dist/__tests__/components/breadcrumbs-is-id.test.js.map +1 -0
  8. package/dist/__tests__/lib/page-editor-service-api.test.js +40 -0
  9. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  10. package/dist/__tests__/no-server-barrel-imports.test.d.ts +2 -0
  11. package/dist/__tests__/no-server-barrel-imports.test.d.ts.map +1 -0
  12. package/dist/__tests__/no-server-barrel-imports.test.js +50 -0
  13. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -0
  14. package/dist/__tests__/router/navigate-pushstate.test.d.ts +2 -0
  15. package/dist/__tests__/router/navigate-pushstate.test.d.ts.map +1 -0
  16. package/dist/__tests__/router/navigate-pushstate.test.js +37 -0
  17. package/dist/__tests__/router/navigate-pushstate.test.js.map +1 -0
  18. package/dist/__tests__/views/appearance-settings.render.test.js +34 -0
  19. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  20. package/dist/components/Breadcrumbs.d.ts +1 -0
  21. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  22. package/dist/components/Breadcrumbs.js +10 -2
  23. package/dist/components/Breadcrumbs.js.map +1 -1
  24. package/dist/components/MediaPickerModal.d.ts +9 -1
  25. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  26. package/dist/components/MediaPickerModal.js +11 -5
  27. package/dist/components/MediaPickerModal.js.map +1 -1
  28. package/dist/hooks/useBuilderState.js +1 -1
  29. package/dist/hooks/useBuilderState.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts.map +1 -1
  31. package/dist/lib/page-editor-service.js +10 -3
  32. package/dist/lib/page-editor-service.js.map +1 -1
  33. package/dist/router/index.d.ts.map +1 -1
  34. package/dist/router/index.js +6 -1
  35. package/dist/router/index.js.map +1 -1
  36. package/dist/views/page-builder/BlockEditor.js +1 -1
  37. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  38. package/dist/views/page-builder/BlockPicker.js +1 -1
  39. package/dist/views/page-builder/BlockPicker.js.map +1 -1
  40. package/dist/views/page-builder/DesignScore.js +1 -1
  41. package/dist/views/page-builder/DesignScore.js.map +1 -1
  42. package/dist/views/page-builder/SEOPanel.js +1 -1
  43. package/dist/views/page-builder/SEOPanel.js.map +1 -1
  44. package/dist/views/page-builder/SavedSections.d.ts.map +1 -1
  45. package/dist/views/page-builder/SavedSections.js +1 -1
  46. package/dist/views/page-builder/SavedSections.js.map +1 -1
  47. package/dist/views/page-builder/TemplatePicker.js +1 -1
  48. package/dist/views/page-builder/TemplatePicker.js.map +1 -1
  49. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  50. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  51. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  52. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  53. package/dist/views/page-editor/SectionInspector.js +6 -1
  54. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  55. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  56. package/dist/views/page-editor/SectionsPanel.js +7 -4
  57. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  58. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  59. package/dist/views/page-editor/sections/SectionRenderer.js +14 -1
  60. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  61. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  62. package/dist/views/settings/BrandingCard.js +15 -4
  63. package/dist/views/settings/BrandingCard.js.map +1 -1
  64. package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
  65. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  66. package/dist/views/settings/useAppearanceSettings.js +24 -0
  67. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/AdminRoot.tsx +12 -0
  70. package/src/__tests__/components/breadcrumbs-is-id.test.ts +32 -0
  71. package/src/__tests__/lib/page-editor-service-api.test.ts +43 -0
  72. package/src/__tests__/no-server-barrel-imports.test.ts +58 -0
  73. package/src/__tests__/router/navigate-pushstate.test.ts +46 -0
  74. package/src/__tests__/views/appearance-settings.render.test.tsx +45 -0
  75. package/src/components/Breadcrumbs.tsx +11 -2
  76. package/src/components/MediaPickerModal.tsx +68 -39
  77. package/src/hooks/useBuilderState.ts +1 -1
  78. package/src/lib/page-editor-service.ts +10 -3
  79. package/src/router/index.ts +6 -1
  80. package/src/views/page-builder/BlockEditor.tsx +1 -1
  81. package/src/views/page-builder/BlockPicker.tsx +1 -1
  82. package/src/views/page-builder/DesignScore.tsx +1 -1
  83. package/src/views/page-builder/SEOPanel.tsx +1 -1
  84. package/src/views/page-builder/SavedSections.tsx +6 -1
  85. package/src/views/page-builder/TemplatePicker.tsx +1 -1
  86. package/src/views/page-editor/PageSectionEditor.tsx +24 -1
  87. package/src/views/page-editor/SectionInspector.tsx +50 -1
  88. package/src/views/page-editor/SectionsPanel.tsx +54 -38
  89. package/src/views/page-editor/sections/SectionRenderer.tsx +25 -1
  90. package/src/views/settings/BrandingCard.tsx +37 -2
  91. package/src/views/settings/useAppearanceSettings.ts +39 -0
package/src/AdminRoot.tsx CHANGED
@@ -5,6 +5,10 @@ import { toast } from 'sonner'
5
5
  import { Layout } from './layout/Layout.js'
6
6
  import { useAdminRouter } from './router/index.js'
7
7
  import { cmsApi, ensureCsrfToken } from './lib/api.js'
8
+ import {
9
+ registerSectionTypes,
10
+ resetCustomSectionTypes,
11
+ } from '@actuate-media/cms-core/page-sections'
8
12
  import { Dashboard } from './views/Dashboard.js'
9
13
  import { CollectionList } from './views/CollectionList.js'
10
14
  import { DocumentEdit } from './views/DocumentEdit.js'
@@ -155,6 +159,14 @@ function AdminShell({
155
159
  }
156
160
  }, [session])
157
161
 
162
+ // Register config-declared custom section types into the client registry so
163
+ // the Page/Post editors recognize them (Add Section gallery + field-driven
164
+ // inspector). Reset first so a config change never leaves stale types.
165
+ useEffect(() => {
166
+ resetCustomSectionTypes()
167
+ registerSectionTypes(config?.sections?.customTypes)
168
+ }, [config])
169
+
158
170
  const shortcuts = useMemo(
159
171
  () => ({
160
172
  'mod+k': () => {
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { isId } from '../../components/Breadcrumbs.js'
4
+
5
+ /**
6
+ * `isId` decides whether a path segment is a document id (shown as "Edit ...")
7
+ * versus a human-readable route segment (title-cased). Prisma uses
8
+ * `@default(cuid())`, so the regression here is CUIDs being mistaken for slugs
9
+ * and rendered raw in the breadcrumb trail.
10
+ */
11
+ describe('Breadcrumbs.isId', () => {
12
+ it('recognizes cuid ids (Prisma @default(cuid()))', () => {
13
+ expect(isId('clp1q2w3e4r5t6y7u8i9o0p1a')).toBe(true)
14
+ expect(isId('cm5xk3a9b0001qz8f4g7h2j6k')).toBe(true)
15
+ })
16
+
17
+ it('recognizes cuid2 / long base36 token ids', () => {
18
+ expect(isId('tz4a8f5d2e9b1c6h3k7m0p2q')).toBe(true)
19
+ })
20
+
21
+ it('still recognizes numeric and hex ids', () => {
22
+ expect(isId('12345')).toBe(true)
23
+ expect(isId('a1b2c3d4e5f6')).toBe(true)
24
+ })
25
+
26
+ it('does not treat human-readable slugs as ids', () => {
27
+ expect(isId('about')).toBe(false)
28
+ expect(isId('contact-us')).toBe(false)
29
+ expect(isId('new')).toBe(false)
30
+ expect(isId('page-builder')).toBe(false)
31
+ })
32
+ })
@@ -141,6 +141,49 @@ describe('savePageDraft', () => {
141
141
  cmsApi.mockResolvedValue({ error: 'Forbidden', status: 403 })
142
142
  await expect(savePageDraft(draft('p1'))).rejects.toThrow('Forbidden')
143
143
  })
144
+
145
+ it('round-trips externally-managed (unmanaged) sections on save without data loss', async () => {
146
+ // Load a page that carries an unknown section type (seed/code-owned).
147
+ cmsApi.mockResolvedValueOnce({
148
+ data: {
149
+ id: 'p9',
150
+ title: 'Landing',
151
+ slug: 'landing',
152
+ status: 'DRAFT',
153
+ data: {
154
+ path: '/landing',
155
+ sections: [
156
+ { id: 's1', sectionType: 'hero', content: { heading: 'Hi' } },
157
+ { id: 's2', sectionType: 'opal-hero', content: { title: 'Custom', cta: '/x' } },
158
+ ],
159
+ },
160
+ },
161
+ status: 200,
162
+ })
163
+ const page = await fetchPageForEditor('p9')
164
+ expect(page.sections.find((s) => s.id === 's2')?.unmanaged).toBe(true)
165
+
166
+ cmsApi.mockResolvedValueOnce({
167
+ data: {
168
+ id: 'p9',
169
+ title: 'Landing',
170
+ slug: 'landing',
171
+ status: 'DRAFT',
172
+ data: { sections: [] },
173
+ },
174
+ status: 200,
175
+ })
176
+ await savePageDraft(page)
177
+
178
+ const [, opts] = cmsApi.mock.calls[1] as [string, RequestInit]
179
+ const body = JSON.parse(opts.body as string)
180
+ const opal = body.sections.find((s: { sectionType: string }) => s.sectionType === 'opal-hero')
181
+ // The unknown section survives the save, content intact...
182
+ expect(opal).toBeTruthy()
183
+ expect(opal.content).toEqual({ title: 'Custom', cta: '/x' })
184
+ // ...but the derived `unmanaged` marker is never persisted.
185
+ expect('unmanaged' in opal).toBe(false)
186
+ })
144
187
  })
145
188
 
146
189
  describe('publishPage', () => {
@@ -0,0 +1,58 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ // The cms-core main barrel (`@actuate-media/cms-core`) re-exports server-only
7
+ // code (getDB, pg, sharp, graphql, codegen). Importing a runtime *value* from
8
+ // it inside cms-admin drags that server graph into the client admin bundle —
9
+ // the bug Opal hit where Turbopack pulled `pg`/`fs` into the browser build.
10
+ //
11
+ // Type-only imports (`import type` / `export type`) are erased by tsc and are
12
+ // safe. Page-builder runtime utilities must come from the client-safe
13
+ // `@actuate-media/cms-core/page-builder` subpath instead. This test fails if
14
+ // any source file reintroduces a value import from the bare barrel.
15
+
16
+ const SRC_DIR = fileURLToPath(new URL('../', import.meta.url))
17
+
18
+ function collectSourceFiles(dir: string, acc: string[] = []): string[] {
19
+ for (const entry of readdirSync(dir)) {
20
+ const full = join(dir, entry)
21
+ if (statSync(full).isDirectory()) {
22
+ if (entry === '__tests__' || entry === 'node_modules' || entry === 'dist') continue
23
+ collectSourceFiles(full, acc)
24
+ } else if (/\.(ts|tsx)$/.test(entry)) {
25
+ acc.push(full)
26
+ }
27
+ }
28
+ return acc
29
+ }
30
+
31
+ // Matches `import ... from '@actuate-media/cms-core'` and the `export ... from`
32
+ // form, capturing whether the `type` modifier follows the keyword. The import
33
+ // clause is restricted to a brace block / namespace / default binding so the
34
+ // match can't span across adjacent statements. The closing quote immediately
35
+ // after `cms-core` excludes subpath imports like `.../page-builder`.
36
+ const BARE_BARREL =
37
+ /\b(import|export)\s+(type\s+)?(?:\{[^}]*\}|\*\s+as\s+[A-Za-z_$][\w$]*|[A-Za-z_$][\w$]*(?:\s*,\s*\{[^}]*\})?)\s+from\s+['"]@actuate-media\/cms-core['"]/g
38
+
39
+ describe('cms-admin client/server bundle boundary', () => {
40
+ it('never imports runtime values from the bare @actuate-media/cms-core barrel', () => {
41
+ const offenders: string[] = []
42
+
43
+ for (const file of collectSourceFiles(SRC_DIR)) {
44
+ const content = readFileSync(file, 'utf-8')
45
+ for (const match of content.matchAll(BARE_BARREL)) {
46
+ const isTypeOnly = Boolean(match[2])
47
+ if (!isTypeOnly) {
48
+ offenders.push(`${relative(SRC_DIR, file)}: ${match[0].replace(/\s+/g, ' ').trim()}`)
49
+ }
50
+ }
51
+ }
52
+
53
+ expect(
54
+ offenders,
55
+ `Use '@actuate-media/cms-core/page-builder' (or 'import type') instead:\n${offenders.join('\n')}`,
56
+ ).toEqual([])
57
+ })
58
+ })
@@ -0,0 +1,46 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { act, renderHook } from '@testing-library/react'
4
+
5
+ import { useAdminRouter } from '../../router/index.js'
6
+
7
+ // Next.js 16 patches `history.pushState` and only short-circuits to the native
8
+ // implementation when the state object carries `__NA` (or `_N`). Without that
9
+ // marker it runs URL parsing that throws "Invalid URL" for the admin SPA. These
10
+ // tests pin the marker so the Next 16 admin routing regression can't reappear.
11
+ describe('useAdminRouter navigate', () => {
12
+ let pushStateSpy: ReturnType<typeof vi.spyOn>
13
+
14
+ beforeEach(() => {
15
+ pushStateSpy = vi.spyOn(window.history, 'pushState')
16
+ })
17
+
18
+ afterEach(() => {
19
+ pushStateSpy.mockRestore()
20
+ })
21
+
22
+ it('marks pushState state with __NA so Next.js 16 leaves it untouched', () => {
23
+ const { result } = renderHook(() => useAdminRouter('/admin', '/'))
24
+
25
+ act(() => {
26
+ result.current.navigate('/collections/posts')
27
+ })
28
+
29
+ expect(pushStateSpy).toHaveBeenCalledTimes(1)
30
+ const [state, , url] = pushStateSpy.mock.calls[0]!
31
+ expect(state).toMatchObject({ adminPath: '/collections/posts', __NA: true })
32
+ expect(url).toBe('/admin/collections/posts')
33
+ })
34
+
35
+ it('uses the bare basePath (with __NA) when navigating to root', () => {
36
+ const { result } = renderHook(() => useAdminRouter('/securelogin', '/'))
37
+
38
+ act(() => {
39
+ result.current.navigate('/')
40
+ })
41
+
42
+ const [state, , url] = pushStateSpy.mock.calls[0]!
43
+ expect(state).toMatchObject({ adminPath: '/', __NA: true })
44
+ expect(url).toBe('/securelogin')
45
+ })
46
+ })
@@ -17,6 +17,24 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?
17
17
  data: { id: 'new-logo', url: 'https://cdn.example.com/x.svg', mimeType: 'image/svg+xml' },
18
18
  status: 200,
19
19
  }
20
+ if (endpoint.startsWith('/media?') && method === 'GET')
21
+ return {
22
+ data: {
23
+ items: [
24
+ {
25
+ id: 'existing-logo',
26
+ filename: 'existing.png',
27
+ storageKey: 'https://cdn.example.com/existing.png',
28
+ url: 'https://cdn.example.com/existing.png',
29
+ mimeType: 'image/png',
30
+ fileSize: 1234,
31
+ width: 400,
32
+ height: 100,
33
+ },
34
+ ],
35
+ },
36
+ status: 200,
37
+ }
20
38
  return { data: {}, status: 200 }
21
39
  })
22
40
 
@@ -120,6 +138,33 @@ describe('AppearanceTab', () => {
120
138
  )
121
139
  })
122
140
 
141
+ it('references an existing media-library asset without re-uploading', async () => {
142
+ render(<AppearanceTab role="ADMIN" />)
143
+ await screen.findByText('Branding')
144
+
145
+ fireEvent.click(screen.getByRole('button', { name: /choose primary logo from media library/i }))
146
+
147
+ const thumb = await screen.findByRole('button', { name: /existing\.png/i })
148
+ fireEvent.click(thumb)
149
+
150
+ // No upload should be issued — we reference the existing asset by id.
151
+ expect(cmsApi.mock.calls.some(([endpoint]) => endpoint === '/media/upload')).toBe(false)
152
+
153
+ const save = await screen.findByRole('button', { name: 'Save Changes' })
154
+ await waitFor(() => expect((save as HTMLButtonElement).disabled).toBe(false))
155
+ fireEvent.click(save)
156
+
157
+ await waitFor(() =>
158
+ expect(cmsApi).toHaveBeenCalledWith(
159
+ '/globals/settings',
160
+ expect.objectContaining({
161
+ method: 'PUT',
162
+ body: expect.stringContaining('"publicPrimaryLogoAssetId":"existing-logo"'),
163
+ }),
164
+ ),
165
+ )
166
+ })
167
+
123
168
  it('is read-only for non-admins', async () => {
124
169
  render(<AppearanceTab role="EDITOR" />)
125
170
 
@@ -27,8 +27,17 @@ function labelFor(segment: string): string {
27
27
  )
28
28
  }
29
29
 
30
- function isId(segment: string): boolean {
31
- return /^\d+$/.test(segment) || /^[0-9a-f]{8,}$/i.test(segment)
30
+ export function isId(segment: string): boolean {
31
+ return (
32
+ // numeric id
33
+ /^\d+$/.test(segment) ||
34
+ // hex id (e.g. ObjectId / md5-style)
35
+ /^[0-9a-f]{8,}$/i.test(segment) ||
36
+ // cuid (Prisma's @default(cuid())): leading "c" + base36, ~25 chars
37
+ /^c[a-z0-9]{20,}$/i.test(segment) ||
38
+ // cuid2 / other long base36 token ids (24+ chars, no separators)
39
+ /^[a-z0-9]{24,}$/i.test(segment)
40
+ )
32
41
  }
33
42
 
34
43
  export interface BreadcrumbsProps {
@@ -10,6 +10,8 @@ interface MediaItem {
10
10
  id: string
11
11
  filename: string
12
12
  storageKey: string
13
+ /** Normalized public URL from the media list endpoint (preferred for display). */
14
+ url?: string
13
15
  mimeType: string
14
16
  fileSize: number
15
17
  width?: number
@@ -20,6 +22,8 @@ interface MediaItem {
20
22
  export interface SelectedMediaItem {
21
23
  id: string
22
24
  storageKey: string
25
+ /** Normalized public URL (falls back to storageKey when absent). */
26
+ url: string
23
27
  filename: string
24
28
  mimeType: string
25
29
  width?: number
@@ -36,6 +40,12 @@ export interface MediaPickerModalProps {
36
40
  */
37
41
  onSelectItem?: (item: SelectedMediaItem) => void
38
42
  accept?: string
43
+ /**
44
+ * Hide the "Upload New" tab and show only the existing library. Used where a
45
+ * caller has its own upload path (e.g. brand assets, which upload with
46
+ * format-preserving options) but still wants to reuse library assets.
47
+ */
48
+ libraryOnly?: boolean
39
49
  }
40
50
 
41
51
  export function MediaPickerModal({
@@ -44,6 +54,7 @@ export function MediaPickerModal({
44
54
  onSelect,
45
55
  onSelectItem,
46
56
  accept,
57
+ libraryOnly = false,
47
58
  }: MediaPickerModalProps) {
48
59
  const [tab, setTab] = useState<'library' | 'upload'>('library')
49
60
  const [search, setSearch] = useState('')
@@ -74,11 +85,12 @@ export function MediaPickerModal({
74
85
  toast.error(res.error)
75
86
  } else if (res.data) {
76
87
  const mediaItem = res.data as MediaItem
77
- const url = mediaItem.storageKey ?? ''
88
+ const url = mediaItem.url || mediaItem.storageKey || ''
78
89
  toast.success(`Uploaded ${file.name}`)
79
90
  onSelectItem?.({
80
91
  id: mediaItem.id,
81
92
  storageKey: mediaItem.storageKey,
93
+ url,
82
94
  filename: mediaItem.filename ?? file.name,
83
95
  mimeType: mediaItem.mimeType ?? file.type,
84
96
  width: mediaItem.width,
@@ -93,15 +105,17 @@ export function MediaPickerModal({
93
105
  }
94
106
 
95
107
  function handleSelectItem(item: MediaItem) {
108
+ const url = item.url || item.storageKey || ''
96
109
  onSelectItem?.({
97
110
  id: item.id,
98
111
  storageKey: item.storageKey,
112
+ url,
99
113
  filename: item.filename,
100
114
  mimeType: item.mimeType,
101
115
  width: item.width,
102
116
  height: item.height,
103
117
  })
104
- onSelect(item.storageKey, item.filename)
118
+ onSelect(url, item.filename)
105
119
  onClose()
106
120
  }
107
121
 
@@ -121,31 +135,33 @@ export function MediaPickerModal({
121
135
  </button>
122
136
  </div>
123
137
 
124
- <div className="flex border-b border-gray-200">
125
- <button
126
- onClick={() => setTab('library')}
127
- className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
128
- tab === 'library'
129
- ? 'border-b-2 border-blue-600 text-blue-600'
130
- : 'text-gray-500 hover:text-gray-700'
131
- }`}
132
- >
133
- Media Library
134
- </button>
135
- <button
136
- onClick={() => setTab('upload')}
137
- className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
138
- tab === 'upload'
139
- ? 'border-b-2 border-blue-600 text-blue-600'
140
- : 'text-gray-500 hover:text-gray-700'
141
- }`}
142
- >
143
- Upload New
144
- </button>
145
- </div>
138
+ {!libraryOnly && (
139
+ <div className="flex border-b border-gray-200">
140
+ <button
141
+ onClick={() => setTab('library')}
142
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
143
+ tab === 'library'
144
+ ? 'border-b-2 border-blue-600 text-blue-600'
145
+ : 'text-gray-500 hover:text-gray-700'
146
+ }`}
147
+ >
148
+ Media Library
149
+ </button>
150
+ <button
151
+ onClick={() => setTab('upload')}
152
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
153
+ tab === 'upload'
154
+ ? 'border-b-2 border-blue-600 text-blue-600'
155
+ : 'text-gray-500 hover:text-gray-700'
156
+ }`}
157
+ >
158
+ Upload New
159
+ </button>
160
+ </div>
161
+ )}
146
162
 
147
163
  <div className="flex-1 overflow-y-auto p-4">
148
- {tab === 'library' ? (
164
+ {libraryOnly || tab === 'library' ? (
149
165
  <>
150
166
  <div className="relative mb-4">
151
167
  <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
@@ -168,20 +184,33 @@ export function MediaPickerModal({
168
184
  </div>
169
185
  ) : (
170
186
  <div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
171
- {imageItems.map((item: MediaItem) => (
172
- <button
173
- key={item.id}
174
- onClick={() => handleSelectItem(item)}
175
- className="group relative aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-100 transition-colors hover:border-blue-500"
176
- >
177
- <div className="flex h-full w-full items-center justify-center">
178
- <ImageIcon className="h-8 w-8 text-gray-300" />
179
- </div>
180
- <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
181
- <p className="truncate text-xs text-white">{item.filename}</p>
182
- </div>
183
- </button>
184
- ))}
187
+ {imageItems.map((item: MediaItem) => {
188
+ const src = item.url || item.storageKey
189
+ return (
190
+ <button
191
+ key={item.id}
192
+ onClick={() => handleSelectItem(item)}
193
+ title={item.filename}
194
+ className="group relative aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-100 transition-colors hover:border-blue-500"
195
+ >
196
+ {src ? (
197
+ <img
198
+ src={src}
199
+ alt={item.filename}
200
+ loading="lazy"
201
+ className="h-full w-full object-contain"
202
+ />
203
+ ) : (
204
+ <div className="flex h-full w-full items-center justify-center">
205
+ <ImageIcon className="h-8 w-8 text-gray-300" />
206
+ </div>
207
+ )}
208
+ <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
209
+ <p className="truncate text-xs text-white">{item.filename}</p>
210
+ </div>
211
+ </button>
212
+ )
213
+ })}
185
214
  </div>
186
215
  )}
187
216
  </>
@@ -23,7 +23,7 @@ import {
23
23
  createColumn,
24
24
  createBlock,
25
25
  hasChildren,
26
- } from '@actuate-media/cms-core'
26
+ } from '@actuate-media/cms-core/page-builder'
27
27
 
28
28
  export type DeviceMode = 'desktop' | 'tablet' | 'mobile'
29
29
  export type PanelTab = 'block' | 'node' | 'page' | 'seo' | 'design'
@@ -112,7 +112,9 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
112
112
  errors.push('Add at least one visible section before publishing.')
113
113
  }
114
114
 
115
- for (const section of visible) {
115
+ // Unmanaged (externally-managed) sections have no registered field schema,
116
+ // so they can't be content-validated here and must not block publish.
117
+ for (const section of visible.filter((s) => !s.unmanaged)) {
116
118
  const def = getSectionType(section.sectionType)
117
119
  const label = section.name || def?.name || 'Section'
118
120
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -216,7 +218,9 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
216
218
  slug,
217
219
  path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
218
220
  status: doc.status ?? 'DRAFT',
219
- sections: coerceSectionsCore(data.sections),
221
+ // Preserve unknown (externally-managed) sections so the editor can show
222
+ // them read-only and round-trip them on save instead of dropping them.
223
+ sections: coerceSectionsCore(data.sections, { preserveUnknown: true }),
220
224
  seoTitle: typeof data.metaTitle === 'string' ? data.metaTitle : '',
221
225
  seoDescription: typeof data.metaDescription === 'string' ? data.metaDescription : '',
222
226
  publishedAt: doc.publishedAt ?? null,
@@ -252,7 +256,10 @@ function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string
252
256
  title: page.title,
253
257
  slug: page.slug,
254
258
  path: page.path,
255
- sections: page.sections,
259
+ // Unmanaged sections are part of `page.sections`, so they round-trip on
260
+ // every save (no data loss). Strip the derived `unmanaged` marker — it is
261
+ // recomputed from the registry on read, so persisting it would only stale.
262
+ sections: page.sections.map(({ unmanaged: _unmanaged, ...rest }) => rest),
256
263
  metaTitle: page.seoTitle,
257
264
  metaDescription: page.seoDescription,
258
265
  ...extra,
@@ -41,7 +41,12 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
41
41
  setCurrentPath(normalizedPath)
42
42
 
43
43
  const fullUrl = normalizedPath === '/' ? baseRef.current : `${baseRef.current}${normalizedPath}`
44
- window.history.pushState({ adminPath: normalizedPath }, '', fullUrl)
44
+ // `__NA: true` marks this as a native history entry so Next.js 16's patched
45
+ // `history.pushState` short-circuits to the original implementation instead
46
+ // of running its URL-parsing path (which throws "Invalid URL"). The admin is
47
+ // a self-contained SPA with its own `popstate` listener, so we deliberately
48
+ // bypass Next's router/RSC sync on in-admin navigation.
49
+ window.history.pushState({ adminPath: normalizedPath, __NA: true }, '', fullUrl)
45
50
  }, [])
46
51
 
47
52
  // Build an absolute (basePath-prefixed) admin URL without navigating. Used
@@ -4,7 +4,7 @@ import { useState, useMemo, useCallback } from 'react'
4
4
  import { Star, Copy, Trash2 } from 'lucide-react'
5
5
  import * as SwitchPrimitive from '@radix-ui/react-switch'
6
6
  import type { BlockNode, BlockTypeDefinition, FieldDefinition } from '@actuate-media/cms-core'
7
- import { BlockCatalog } from '@actuate-media/cms-core'
7
+ import { BlockCatalog } from '@actuate-media/cms-core/page-builder'
8
8
  import { AIBlockAssist } from './AIBlockAssist.js'
9
9
  import { RelationshipField } from './RelationshipField.js'
10
10
  import { TipTapEditor } from '../../components/TipTapEditor.js'
@@ -20,7 +20,7 @@ import {
20
20
  Check,
21
21
  } from 'lucide-react'
22
22
  import type { LucideIcon } from 'lucide-react'
23
- import { BlockCatalog } from '@actuate-media/cms-core'
23
+ import { BlockCatalog } from '@actuate-media/cms-core/page-builder'
24
24
  import type { BlockTypeDefinition, VariantDefinition } from '@actuate-media/cms-core'
25
25
 
26
26
  export interface BlockPickerProps {
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useMemo } from 'react'
4
- import { analyzeDesign } from '@actuate-media/cms-core'
4
+ import { analyzeDesign } from '@actuate-media/cms-core/page-builder'
5
5
  import type {
6
6
  PageNode,
7
7
  DesignAnalysis,
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useMemo } from 'react'
4
- import { analyzeSEO } from '@actuate-media/cms-core'
4
+ import { analyzeSEO } from '@actuate-media/cms-core/page-builder'
5
5
  import type {
6
6
  PageNode,
7
7
  BuilderSEOAnalysis,
@@ -15,7 +15,12 @@ import {
15
15
  } from 'lucide-react'
16
16
  import { toast } from 'sonner'
17
17
  import { cmsApi } from '../../lib/api.js'
18
- import { createSection, createContainer, createRow, createColumn } from '@actuate-media/cms-core'
18
+ import {
19
+ createSection,
20
+ createContainer,
21
+ createRow,
22
+ createColumn,
23
+ } from '@actuate-media/cms-core/page-builder'
19
24
 
20
25
  export interface SavedSectionsProps {
21
26
  onNavigate: (path: string) => void
@@ -14,7 +14,7 @@ import {
14
14
  } from 'lucide-react'
15
15
  import type { LucideIcon } from 'lucide-react'
16
16
  import { cmsApi } from '../../lib/api.js'
17
- import { createEmptyPage } from '@actuate-media/cms-core'
17
+ import { createEmptyPage } from '@actuate-media/cms-core/page-builder'
18
18
  import type { PageNode } from '@actuate-media/cms-core'
19
19
 
20
20
  export interface TemplatePickerProps {
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
- import { AlertTriangle, Blocks, Loader2 } from 'lucide-react'
4
+ 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'
@@ -392,6 +392,12 @@ export function PageSectionEditor({
392
392
  const readOnly = page.builderOwned
393
393
  const effectiveCanEdit = canEdit && !readOnly
394
394
 
395
+ // Sections whose type has no registered definition (seed/code-owned, e.g.
396
+ // `opal-*`). They are preserved verbatim on save but can't be edited here.
397
+ const unmanagedTypes = Array.from(
398
+ new Set(page.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
399
+ )
400
+
395
401
  return (
396
402
  <ErrorBoundary>
397
403
  <div className="bg-background flex h-full flex-col overflow-hidden">
@@ -436,6 +442,23 @@ export function PageSectionEditor({
436
442
  </div>
437
443
  )}
438
444
 
445
+ {unmanagedTypes.length > 0 && (
446
+ <div
447
+ role="status"
448
+ className="border-border bg-muted/50 flex items-start gap-3 border-b px-4 py-2.5"
449
+ >
450
+ <Lock className="text-muted-foreground mt-0.5 shrink-0" size={16} aria-hidden />
451
+ <p className="text-muted-foreground flex-1 text-sm">
452
+ This page has{' '}
453
+ <span className="text-foreground font-medium">
454
+ {unmanagedTypes.length} section type{unmanagedTypes.length > 1 ? 's' : ''}
455
+ </span>{' '}
456
+ managed outside the editor ({unmanagedTypes.join(', ')}). They are preserved on save
457
+ but edited in your seed script or code, not here.
458
+ </p>
459
+ </div>
460
+ )}
461
+
439
462
  {validation && (
440
463
  <ValidationSummary
441
464
  errors={validation.errors}