@actuate-media/cms-admin 0.42.0 → 0.42.2
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/__tests__/lib/post-editor-service.test.js +120 -1
- package/dist/__tests__/lib/post-editor-service.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/__tests__/views/post-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/post-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-section-editor.test.js +96 -0
- package/dist/__tests__/views/post-section-editor.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/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/collection-schema.d.ts +29 -0
- package/dist/lib/collection-schema.d.ts.map +1 -0
- package/dist/lib/collection-schema.js +54 -0
- package/dist/lib/collection-schema.js.map +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +2 -32
- package/dist/lib/page-editor-service.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/lib/post-editor-service.d.ts +1 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +50 -9
- package/dist/lib/post-editor-service.js.map +1 -1
- 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 +5 -2
- 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/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +27 -4
- 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 +2 -2
- package/src/__tests__/lib/post-editor-service.test.ts +136 -0
- package/src/__tests__/views/portal-container.test.tsx +56 -0
- package/src/__tests__/views/post-section-editor.test.tsx +147 -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/layout/Header.tsx +2 -1
- package/src/layout/Sidebar.tsx +2 -1
- package/src/lib/collection-schema.ts +62 -0
- package/src/lib/page-editor-service.ts +3 -35
- package/src/lib/portal-container.ts +14 -0
- package/src/lib/post-editor-service.ts +54 -8
- 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 +5 -2
- package/src/views/page-editor/SectionsPanel.tsx +2 -1
- package/src/views/post-editor/PostSectionEditor.tsx +49 -2
- package/src/views/settings/components.tsx +2 -1
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* so the field-access layer doesn't strip them on write.
|
|
21
21
|
*/
|
|
22
22
|
import { cmsApi } from './api.js'
|
|
23
|
+
import { missingCollectionFields } from './collection-schema.js'
|
|
23
24
|
import {
|
|
24
25
|
getSectionType,
|
|
26
|
+
generateSectionId,
|
|
25
27
|
createSection,
|
|
26
28
|
addSection,
|
|
27
29
|
removeSection,
|
|
@@ -93,6 +95,30 @@ export interface ValidationResult {
|
|
|
93
95
|
warnings: string[]
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// ─── Schema guard ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Post Editor persists these as top-level keys on the post-type
|
|
102
|
+
* collection's documents. The server's field-access layer silently drops any
|
|
103
|
+
* key not declared as a field on the collection, so a post-type collection
|
|
104
|
+
* missing e.g. `sections` would discard all section content on save with no
|
|
105
|
+
* error. Mirrors the Page Editor's `missingPageFields` guard.
|
|
106
|
+
*/
|
|
107
|
+
const REQUIRED_POST_FIELDS = [
|
|
108
|
+
'sections',
|
|
109
|
+
'excerpt',
|
|
110
|
+
'featuredImage',
|
|
111
|
+
'body',
|
|
112
|
+
'category',
|
|
113
|
+
'publishDate',
|
|
114
|
+
'metaTitle',
|
|
115
|
+
'metaDescription',
|
|
116
|
+
] as const
|
|
117
|
+
|
|
118
|
+
export function missingPostFields(config: unknown, postType: string): string[] {
|
|
119
|
+
return missingCollectionFields(config, postType, REQUIRED_POST_FIELDS)
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
// ─── Validation ──────────────────────────────────────────────────────
|
|
97
123
|
|
|
98
124
|
function isBlank(v: unknown): boolean {
|
|
@@ -113,7 +139,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
113
139
|
|
|
114
140
|
if (forPublish) {
|
|
115
141
|
const visible = post.sections.filter((s) => s.visible)
|
|
116
|
-
|
|
142
|
+
// Unmanaged (externally-managed) sections have no registered field schema,
|
|
143
|
+
// so they can't be content-validated here and must not block publish.
|
|
144
|
+
for (const section of visible.filter((s) => !s.unmanaged)) {
|
|
117
145
|
const def = getSectionType(section.sectionType)
|
|
118
146
|
const label = section.name || def?.name || 'Section'
|
|
119
147
|
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
@@ -133,7 +161,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
133
161
|
export function validateTemplate(template: EditorPostTemplate): ValidationResult {
|
|
134
162
|
const errors: string[] = []
|
|
135
163
|
const warnings: string[] = []
|
|
136
|
-
for (const section of template.sections.filter((s) => s.visible)) {
|
|
164
|
+
for (const section of template.sections.filter((s) => s.visible && !s.unmanaged)) {
|
|
137
165
|
const def = getSectionType(section.sectionType)
|
|
138
166
|
const label = section.name || def?.name || 'Section'
|
|
139
167
|
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
@@ -175,7 +203,9 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
|
|
|
175
203
|
category: dataStr(data, 'category'),
|
|
176
204
|
status: doc.status ?? 'DRAFT',
|
|
177
205
|
publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
|
|
178
|
-
|
|
206
|
+
// Preserve unknown (externally-managed) sections so the editor can show
|
|
207
|
+
// them read-only and round-trip them on save instead of dropping them.
|
|
208
|
+
sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
|
|
179
209
|
seoTitle: dataStr(data, 'metaTitle'),
|
|
180
210
|
seoDescription: dataStr(data, 'metaDescription'),
|
|
181
211
|
publishedAt: doc.publishedAt ?? null,
|
|
@@ -216,7 +246,7 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
|
|
|
216
246
|
const r = res.data
|
|
217
247
|
const docId = r.docId ?? null
|
|
218
248
|
const header = coercePostHeader(r.header)
|
|
219
|
-
let sections = coerceSections(r.sections)
|
|
249
|
+
let sections = coerceSections(r.sections, { onUnknown: 'preserve' })
|
|
220
250
|
// Defend against an older server (or an empty stored template): an unsaved
|
|
221
251
|
// type should still seed the default sections so authors aren't faced with a
|
|
222
252
|
// blank canvas.
|
|
@@ -230,7 +260,10 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
|
|
|
230
260
|
export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
|
|
231
261
|
const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
|
|
232
262
|
method: 'PUT',
|
|
233
|
-
body: JSON.stringify({
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
header: template.header,
|
|
265
|
+
sections: template.sections.map(stripUnmanagedMarker),
|
|
266
|
+
}),
|
|
234
267
|
})
|
|
235
268
|
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
|
|
236
269
|
const r = res.data
|
|
@@ -238,7 +271,7 @@ export async function saveTemplate(template: EditorPostTemplate): Promise<Editor
|
|
|
238
271
|
docId: r.docId ?? null,
|
|
239
272
|
postType: template.postType,
|
|
240
273
|
header: coercePostHeader(r.header),
|
|
241
|
-
sections: coerceSections(r.sections),
|
|
274
|
+
sections: coerceSections(r.sections, { onUnknown: 'preserve' }),
|
|
242
275
|
}
|
|
243
276
|
}
|
|
244
277
|
|
|
@@ -263,6 +296,15 @@ interface PostWritePayload {
|
|
|
263
296
|
metaDescription: string
|
|
264
297
|
}
|
|
265
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Strip the derived `unmanaged` marker before persisting — it is recomputed
|
|
301
|
+
* from the registry on read, so persisting it would only stale.
|
|
302
|
+
*/
|
|
303
|
+
function stripUnmanagedMarker(section: PageSection): Omit<PageSection, 'unmanaged'> {
|
|
304
|
+
const { unmanaged: _unmanaged, ...rest } = section
|
|
305
|
+
return rest
|
|
306
|
+
}
|
|
307
|
+
|
|
266
308
|
function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string {
|
|
267
309
|
const payload: PostWritePayload & Record<string, unknown> = {
|
|
268
310
|
title: post.title,
|
|
@@ -272,7 +314,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
|
|
|
272
314
|
body: post.body,
|
|
273
315
|
category: post.category,
|
|
274
316
|
publishDate: post.publishDate,
|
|
275
|
-
sections
|
|
317
|
+
// Unmanaged sections are part of `post.sections`, so they round-trip on
|
|
318
|
+
// every save (no data loss).
|
|
319
|
+
sections: post.sections.map(stripUnmanagedMarker),
|
|
276
320
|
metaTitle: post.seoTitle,
|
|
277
321
|
metaDescription: post.seoDescription,
|
|
278
322
|
...extra,
|
|
@@ -318,7 +362,9 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
|
|
|
318
362
|
const template = await fetchTemplateForEditor(postType)
|
|
319
363
|
// Clone with fresh ids so the post owns its own sections, decoupled from
|
|
320
364
|
// the template (editing the post never mutates the type template).
|
|
321
|
-
|
|
365
|
+
// `generateSectionId` (not `createSection`) because preserved unmanaged
|
|
366
|
+
// template sections have no registered type and `createSection` throws.
|
|
367
|
+
sections = template.sections.map((s) => ({ ...s, id: generateSectionId() }))
|
|
322
368
|
} catch {
|
|
323
369
|
// No template / fetch failure → start from an empty section list.
|
|
324
370
|
sections = []
|
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"
|
|
@@ -58,8 +58,11 @@ interface PageSectionEditorProps {
|
|
|
58
58
|
|
|
59
59
|
function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
|
|
60
60
|
const role: string | undefined = session?.user?.role
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// Default closed: when we can't identify a role, hide the edit/publish
|
|
62
|
+
// affordances rather than exposing them. The server independently enforces
|
|
63
|
+
// access on every write, so this is a UX guard — but it should fail safe.
|
|
64
|
+
// (Matches the Post editor's behavior.)
|
|
65
|
+
if (!role) return { canEdit: false, canPublish: false }
|
|
63
66
|
const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
|
|
64
67
|
const publishers = ['ADMIN', 'EDITOR']
|
|
65
68
|
return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }
|
|
@@ -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}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
-
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
4
|
+
import { AlertTriangle, 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'
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
emptyPostFromTemplate,
|
|
14
14
|
fetchPostForEditor,
|
|
15
15
|
fetchTemplateForEditor,
|
|
16
|
+
missingPostFields,
|
|
16
17
|
moveSection,
|
|
17
18
|
publishPost,
|
|
18
19
|
removeSection,
|
|
@@ -123,12 +124,35 @@ export function PostSectionEditor({
|
|
|
123
124
|
return col?.labels?.singular ?? postType
|
|
124
125
|
}, [config, postType])
|
|
125
126
|
|
|
127
|
+
// A misconfigured post-type collection (missing the fields the editor
|
|
128
|
+
// writes) would silently drop content on save — detect it up front.
|
|
129
|
+
// Memoized to a string/null so the load effect can depend on it without
|
|
130
|
+
// re-running when `config`'s object identity changes between renders.
|
|
131
|
+
const schemaError = useMemo(() => {
|
|
132
|
+
const missing = missingPostFields(config, postType)
|
|
133
|
+
if (missing.length === 0) return null
|
|
134
|
+
const plural = missing.length > 1
|
|
135
|
+
return (
|
|
136
|
+
`The "${postType}" collection is missing required field${plural ? 's' : ''}: ` +
|
|
137
|
+
`${missing.join(', ')}. Declare ${plural ? 'them' : 'it'} in your CMS config ` +
|
|
138
|
+
`so the post editor can save without losing content.`
|
|
139
|
+
)
|
|
140
|
+
}, [config, postType])
|
|
141
|
+
|
|
126
142
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
127
143
|
useEffect(() => {
|
|
128
144
|
let cancelled = false
|
|
129
145
|
setLoading(true)
|
|
130
146
|
setLoadError(null)
|
|
131
147
|
|
|
148
|
+
// Fail loud on a misconfigured post-type collection rather than letting
|
|
149
|
+
// the user build a post whose content would be silently dropped on save.
|
|
150
|
+
if (schemaError) {
|
|
151
|
+
setLoadError(schemaError)
|
|
152
|
+
setLoading(false)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
// Always load the type template header (the post editor renders it but
|
|
133
157
|
// never edits it — header design lives at the type level).
|
|
134
158
|
fetchTemplateForEditor(postType)
|
|
@@ -160,7 +184,7 @@ export function PostSectionEditor({
|
|
|
160
184
|
return () => {
|
|
161
185
|
cancelled = true
|
|
162
186
|
}
|
|
163
|
-
}, [postType, documentId])
|
|
187
|
+
}, [postType, documentId, schemaError])
|
|
164
188
|
|
|
165
189
|
// ─── Unsaved-changes guard ───────────────────────────────────────────
|
|
166
190
|
useEffect(() => {
|
|
@@ -424,6 +448,12 @@ export function PostSectionEditor({
|
|
|
424
448
|
|
|
425
449
|
const selected = post.sections.find((s) => s.id === selectedId) ?? null
|
|
426
450
|
|
|
451
|
+
// Sections whose type has no registered definition (seed/code-owned, e.g.
|
|
452
|
+
// `opal-*`). They are preserved verbatim on save but can't be edited here.
|
|
453
|
+
const unmanagedTypes = Array.from(
|
|
454
|
+
new Set(post.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
|
|
455
|
+
)
|
|
456
|
+
|
|
427
457
|
// Section types the canvas renders as placeholders — no built-in body
|
|
428
458
|
// component and no consumer-registered admin renderer. When present the
|
|
429
459
|
// editor surfaces "structure view" messaging and promotes "View on site"
|
|
@@ -460,6 +490,23 @@ export function PostSectionEditor({
|
|
|
460
490
|
structuralTypes={structuralTypes}
|
|
461
491
|
/>
|
|
462
492
|
|
|
493
|
+
{unmanagedTypes.length > 0 && (
|
|
494
|
+
<div
|
|
495
|
+
role="status"
|
|
496
|
+
className="border-border bg-muted/50 flex items-start gap-3 border-b px-4 py-2.5"
|
|
497
|
+
>
|
|
498
|
+
<Lock className="text-muted-foreground mt-0.5 shrink-0" size={16} aria-hidden />
|
|
499
|
+
<p className="text-muted-foreground flex-1 text-sm">
|
|
500
|
+
This post has{' '}
|
|
501
|
+
<span className="text-foreground font-medium">
|
|
502
|
+
{unmanagedTypes.length} section type{unmanagedTypes.length > 1 ? 's' : ''}
|
|
503
|
+
</span>{' '}
|
|
504
|
+
managed outside the editor ({unmanagedTypes.join(', ')}). They are preserved on save
|
|
505
|
+
but edited in your seed script or code, not here.
|
|
506
|
+
</p>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
463
510
|
{validation && (
|
|
464
511
|
<ValidationSummary
|
|
465
512
|
errors={validation.errors}
|
|
@@ -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">
|