@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +8 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts +2 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts.map +1 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.js +28 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +40 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/no-server-barrel-imports.test.d.ts +2 -0
- package/dist/__tests__/no-server-barrel-imports.test.d.ts.map +1 -0
- package/dist/__tests__/no-server-barrel-imports.test.js +50 -0
- package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -0
- package/dist/__tests__/router/navigate-pushstate.test.d.ts +2 -0
- package/dist/__tests__/router/navigate-pushstate.test.d.ts.map +1 -0
- package/dist/__tests__/router/navigate-pushstate.test.js +37 -0
- package/dist/__tests__/router/navigate-pushstate.test.js.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +34 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/components/Breadcrumbs.d.ts +1 -0
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +10 -2
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/MediaPickerModal.d.ts +9 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +11 -5
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/hooks/useBuilderState.js +1 -1
- package/dist/hooks/useBuilderState.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +10 -3
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +6 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/page-builder/BlockEditor.js +1 -1
- package/dist/views/page-builder/BlockEditor.js.map +1 -1
- package/dist/views/page-builder/BlockPicker.js +1 -1
- package/dist/views/page-builder/BlockPicker.js.map +1 -1
- package/dist/views/page-builder/DesignScore.js +1 -1
- package/dist/views/page-builder/DesignScore.js.map +1 -1
- package/dist/views/page-builder/SEOPanel.js +1 -1
- package/dist/views/page-builder/SEOPanel.js.map +1 -1
- package/dist/views/page-builder/SavedSections.d.ts.map +1 -1
- package/dist/views/page-builder/SavedSections.js +1 -1
- package/dist/views/page-builder/SavedSections.js.map +1 -1
- package/dist/views/page-builder/TemplatePicker.js +1 -1
- package/dist/views/page-builder/TemplatePicker.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +5 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +6 -1
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +7 -4
- 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 +14 -1
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/settings/BrandingCard.d.ts.map +1 -1
- package/dist/views/settings/BrandingCard.js +15 -4
- package/dist/views/settings/BrandingCard.js.map +1 -1
- package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +24 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +12 -0
- package/src/__tests__/components/breadcrumbs-is-id.test.ts +32 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +43 -0
- package/src/__tests__/no-server-barrel-imports.test.ts +58 -0
- package/src/__tests__/router/navigate-pushstate.test.ts +46 -0
- package/src/__tests__/views/appearance-settings.render.test.tsx +45 -0
- package/src/components/Breadcrumbs.tsx +11 -2
- package/src/components/MediaPickerModal.tsx +68 -39
- package/src/hooks/useBuilderState.ts +1 -1
- package/src/lib/page-editor-service.ts +10 -3
- package/src/router/index.ts +6 -1
- package/src/views/page-builder/BlockEditor.tsx +1 -1
- package/src/views/page-builder/BlockPicker.tsx +1 -1
- package/src/views/page-builder/DesignScore.tsx +1 -1
- package/src/views/page-builder/SEOPanel.tsx +1 -1
- package/src/views/page-builder/SavedSections.tsx +6 -1
- package/src/views/page-builder/TemplatePicker.tsx +1 -1
- package/src/views/page-editor/PageSectionEditor.tsx +24 -1
- package/src/views/page-editor/SectionInspector.tsx +50 -1
- package/src/views/page-editor/SectionsPanel.tsx +54 -38
- package/src/views/page-editor/sections/SectionRenderer.tsx +25 -1
- package/src/views/settings/BrandingCard.tsx +37 -2
- 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
|
|
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(
|
|
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
|
-
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/router/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -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 {
|
|
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}
|