@actuate-media/cms-admin 0.15.0 → 0.16.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 +148 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
- package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
- package/dist/__tests__/components/posts-badges.test.js +68 -0
- package/dist/__tests__/components/posts-badges.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +137 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service.test.js +140 -0
- package/dist/__tests__/lib/pages-service.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +185 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
- package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/posts-service.test.js +244 -0
- package/dist/__tests__/lib/posts-service.test.js.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.js +98 -0
- package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/pages/tag-badges.d.ts +21 -0
- package/dist/components/pages/tag-badges.d.ts.map +1 -0
- package/dist/components/pages/tag-badges.js +56 -0
- package/dist/components/pages/tag-badges.js.map +1 -0
- package/dist/components/posts/badges.d.ts +25 -0
- package/dist/components/posts/badges.d.ts.map +1 -0
- package/dist/components/posts/badges.js +123 -0
- package/dist/components/posts/badges.js.map +1 -0
- package/dist/components/posts/icons.d.ts +12 -0
- package/dist/components/posts/icons.d.ts.map +1 -0
- package/dist/components/posts/icons.js +26 -0
- package/dist/components/posts/icons.js.map +1 -0
- package/dist/layout/Layout.js +36 -8
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +9 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +100 -48
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +45 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -0
- package/dist/lib/page-editor-service.js +153 -0
- package/dist/lib/page-editor-service.js.map +1 -0
- package/dist/lib/pages-service.d.ts +143 -0
- package/dist/lib/pages-service.d.ts.map +1 -0
- package/dist/lib/pages-service.js +444 -0
- package/dist/lib/pages-service.js.map +1 -0
- package/dist/lib/post-editor-service.d.ts +57 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -0
- package/dist/lib/post-editor-service.js +234 -0
- package/dist/lib/post-editor-service.js.map +1 -0
- package/dist/lib/posts-service.d.ts +73 -0
- package/dist/lib/posts-service.d.ts.map +1 -0
- package/dist/lib/posts-service.js +245 -0
- package/dist/lib/posts-service.js.map +1 -0
- package/dist/lib/preview-link.d.ts +32 -0
- package/dist/lib/preview-link.d.ts.map +1 -0
- package/dist/lib/preview-link.js +40 -0
- package/dist/lib/preview-link.js.map +1 -0
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +9 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +14 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages/PageTagManager.d.ts +14 -0
- package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
- package/dist/views/Pages/PageTagManager.js +145 -0
- package/dist/views/Pages/PageTagManager.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts +18 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -0
- package/dist/views/Pages/PagesListView.js +287 -0
- package/dist/views/Pages/PagesListView.js.map +1 -0
- package/dist/views/Posts/NewPostPicker.d.ts +16 -0
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
- package/dist/views/Posts/NewPostPicker.js +59 -0
- package/dist/views/Posts/NewPostPicker.js.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
- package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +26 -0
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
- package/dist/views/Posts/PostTypesView.js +52 -0
- package/dist/views/Posts/PostTypesView.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts +25 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -0
- package/dist/views/Posts/PostsListView.js +239 -0
- package/dist/views/Posts/PostsListView.js.map +1 -0
- package/dist/views/Posts/post-type-presets.d.ts +32 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
- package/dist/views/Posts/post-type-presets.js +72 -0
- package/dist/views/Posts/post-type-presets.js.map +1 -0
- package/dist/views/page-builder/Widgets.d.ts +13 -0
- package/dist/views/page-builder/Widgets.d.ts.map +1 -0
- package/dist/views/page-builder/Widgets.js +15 -0
- package/dist/views/page-builder/Widgets.js.map +1 -0
- package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
- package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
- package/dist/views/page-editor/AddSectionModal.js +22 -0
- package/dist/views/page-editor/AddSectionModal.js.map +1 -0
- package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
- package/dist/views/page-editor/EditorCanvas.js +64 -0
- package/dist/views/page-editor/EditorCanvas.js.map +1 -0
- package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
- package/dist/views/page-editor/EditorTopBar.js +42 -0
- package/dist/views/page-editor/EditorTopBar.js.map +1 -0
- package/dist/views/page-editor/PagePreview.d.ts +13 -0
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
- package/dist/views/page-editor/PagePreview.js +37 -0
- package/dist/views/page-editor/PagePreview.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.js +272 -0
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.js +52 -0
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +17 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
- package/dist/views/page-editor/SectionInspector.js +98 -0
- package/dist/views/page-editor/SectionInspector.js.map +1 -0
- package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
- package/dist/views/page-editor/SectionsPanel.js +43 -0
- package/dist/views/page-editor/SectionsPanel.js.map +1 -0
- package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
- package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
- package/dist/views/page-editor/ValidationSummary.js +10 -0
- package/dist/views/page-editor/ValidationSummary.js.map +1 -0
- package/dist/views/page-editor/section-icons.d.ts +4 -0
- package/dist/views/page-editor/section-icons.d.ts.map +1 -0
- package/dist/views/page-editor/section-icons.js +20 -0
- package/dist/views/page-editor/section-icons.js.map +1 -0
- package/dist/views/page-editor/section-types.d.ts +11 -0
- package/dist/views/page-editor/section-types.d.ts.map +1 -0
- package/dist/views/page-editor/section-types.js +10 -0
- package/dist/views/page-editor/section-types.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.js +17 -0
- package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.js +12 -0
- package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
- package/dist/views/page-editor/sections/index.d.ts +14 -0
- package/dist/views/page-editor/sections/index.d.ts.map +1 -0
- package/dist/views/page-editor/sections/index.js +12 -0
- package/dist/views/page-editor/sections/index.js.map +1 -0
- package/dist/views/page-editor/sections/parts.d.ts +55 -0
- package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
- package/dist/views/page-editor/sections/parts.js +60 -0
- package/dist/views/page-editor/sections/parts.js.map +1 -0
- package/dist/views/page-editor/viewports.d.ts +14 -0
- package/dist/views/page-editor/viewports.d.ts.map +1 -0
- package/dist/views/page-editor/viewports.js +12 -0
- package/dist/views/page-editor/viewports.js.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.js +58 -0
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.js +53 -0
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
- package/dist/views/post-editor/PostHeader.d.ts +15 -0
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeader.js +27 -0
- package/dist/views/post-editor/PostHeader.js.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.js +21 -0
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
- package/dist/views/post-editor/PostPreview.d.ts +14 -0
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
- package/dist/views/post-editor/PostPreview.js +53 -0
- package/dist/views/post-editor/PostPreview.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.js +290 -0
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.js +144 -0
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +213 -2
- package/src/__tests__/components/posts-badges.test.tsx +77 -0
- package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
- package/src/__tests__/lib/page-editor-service.test.ts +173 -0
- package/src/__tests__/lib/pages-service.test.ts +157 -0
- package/src/__tests__/lib/post-editor-service.test.ts +215 -0
- package/src/__tests__/lib/posts-service.test.ts +279 -0
- package/src/__tests__/views/posts-list-view.test.ts +110 -0
- package/src/components/pages/tag-badges.tsx +104 -0
- package/src/components/posts/badges.tsx +170 -0
- package/src/components/posts/icons.ts +40 -0
- package/src/layout/Layout.tsx +66 -10
- package/src/layout/Sidebar.tsx +116 -50
- package/src/lib/page-editor-service.ts +233 -0
- package/src/lib/pages-service.ts +599 -0
- package/src/lib/post-editor-service.ts +342 -0
- package/src/lib/posts-service.ts +366 -0
- package/src/lib/preview-link.ts +63 -0
- package/src/router/index.ts +10 -1
- package/src/views/Dashboard.tsx +15 -3
- package/src/views/Pages/PageTagManager.tsx +436 -0
- package/src/views/Pages/PagesListView.tsx +944 -0
- package/src/views/Posts/NewPostPicker.tsx +147 -0
- package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
- package/src/views/Posts/PostTypesView.tsx +194 -0
- package/src/views/Posts/PostsListView.tsx +869 -0
- package/src/views/Posts/post-type-presets.ts +105 -0
- package/src/views/page-builder/Widgets.tsx +44 -0
- package/src/views/page-editor/AddSectionModal.tsx +56 -0
- package/src/views/page-editor/EditorCanvas.tsx +127 -0
- package/src/views/page-editor/EditorTopBar.tsx +233 -0
- package/src/views/page-editor/PagePreview.tsx +96 -0
- package/src/views/page-editor/PageSectionEditor.tsx +476 -0
- package/src/views/page-editor/PageSettingsModal.tsx +154 -0
- package/src/views/page-editor/SectionInspector.tsx +599 -0
- package/src/views/page-editor/SectionsPanel.tsx +270 -0
- package/src/views/page-editor/ValidationSummary.tsx +53 -0
- package/src/views/page-editor/section-icons.ts +33 -0
- package/src/views/page-editor/section-types.ts +27 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
- package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
- package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
- package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
- package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
- package/src/views/page-editor/sections/GallerySection.tsx +54 -0
- package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
- package/src/views/page-editor/sections/MissionSection.tsx +22 -0
- package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
- package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
- package/src/views/page-editor/sections/index.ts +13 -0
- package/src/views/page-editor/sections/parts.tsx +113 -0
- package/src/views/page-editor/viewports.ts +25 -0
- package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
- package/src/views/post-editor/PostFieldsModal.tsx +196 -0
- package/src/views/post-editor/PostHeader.tsx +87 -0
- package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
- package/src/views/post-editor/PostPreview.tsx +114 -0
- package/src/views/post-editor/PostSectionEditor.tsx +506 -0
- package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
package/src/layout/Layout.tsx
CHANGED
|
@@ -102,29 +102,26 @@ function computePageActions({
|
|
|
102
102
|
config,
|
|
103
103
|
onNavigate,
|
|
104
104
|
}: PageActionsContext): ReactNode | null {
|
|
105
|
-
// Resolve the "primary" content collection (used for both the dashboard
|
|
106
|
-
// and any explicit /posts route). Prefer a real `posts` or `post`-type
|
|
107
|
-
// collection over the hard-coded slug so admin-managed CMS configs work.
|
|
108
105
|
const collections = collectionList(config)
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
collections.find((c) => c.type === 'post')
|
|
112
|
-
const postsSlug = postsCol?.slug ?? 'posts'
|
|
113
|
-
const postsLabel = postsCol?.labels?.singular ?? 'Post'
|
|
106
|
+
const postCollections = collections.filter((c) => c.type === 'post')
|
|
107
|
+
const newPostPath = resolveNewPostPath(postCollections)
|
|
114
108
|
const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
115
109
|
|
|
116
110
|
const isDashboard = currentPath === '/' || currentPath === '/dashboard'
|
|
117
111
|
|
|
112
|
+
// Dashboard: keep the existing "+ New Post" / "View Site" pair. With
|
|
113
|
+
// a single Post Type we route straight to its editor; with several we
|
|
114
|
+
// route to the picker so the user can choose the type first.
|
|
118
115
|
if (isDashboard) {
|
|
119
116
|
return (
|
|
120
117
|
<>
|
|
121
118
|
<button
|
|
122
119
|
type="button"
|
|
123
|
-
onClick={() => onNavigate(
|
|
120
|
+
onClick={() => onNavigate(newPostPath)}
|
|
124
121
|
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
125
122
|
>
|
|
126
123
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
127
|
-
New
|
|
124
|
+
New Post
|
|
128
125
|
</button>
|
|
129
126
|
{siteUrl ? (
|
|
130
127
|
<a
|
|
@@ -141,6 +138,52 @@ function computePageActions({
|
|
|
141
138
|
)
|
|
142
139
|
}
|
|
143
140
|
|
|
141
|
+
// Posts area — "+ New Post" + "Manage types" on the list page;
|
|
142
|
+
// "+ New Post Type" on the types page. The buttons live in the top
|
|
143
|
+
// bar so the page header stays clean. See the dashboard rule for
|
|
144
|
+
// why these come from a single config-aware helper.
|
|
145
|
+
if (currentPath === '/posts') {
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => onNavigate(newPostPath)}
|
|
150
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
151
|
+
>
|
|
152
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
153
|
+
New Post
|
|
154
|
+
</button>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (currentPath === '/posts/types') {
|
|
159
|
+
return (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => onNavigate('/posts/types/new')}
|
|
163
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
164
|
+
>
|
|
165
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
166
|
+
New Post Type
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Pages list — "+ New page" routes to the page creation flow. Handled
|
|
172
|
+
// explicitly (not via the collection loop) so the label/route stay
|
|
173
|
+
// exact even though `pages` is a reserved route.
|
|
174
|
+
if (currentPath === '/pages') {
|
|
175
|
+
return (
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
onClick={() => onNavigate('/pages/new')}
|
|
179
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
180
|
+
>
|
|
181
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
182
|
+
New page
|
|
183
|
+
</button>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
144
187
|
// List views — surface "+ New X" so authors can keep creating without
|
|
145
188
|
// hunting for a per-list button. Skip on editor views; those have their
|
|
146
189
|
// own toolbar.
|
|
@@ -169,6 +212,19 @@ interface CollectionMeta {
|
|
|
169
212
|
admin?: { hidden?: boolean }
|
|
170
213
|
}
|
|
171
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Where "+ New Post" should land, given the configured post-type
|
|
217
|
+
* collections:
|
|
218
|
+
* - exactly one → straight to that type's editor (`/<slug>/new`)
|
|
219
|
+
* - more than one → the type picker (`/posts/new`)
|
|
220
|
+
* - none → the Post Type setup instructions
|
|
221
|
+
*/
|
|
222
|
+
function resolveNewPostPath(postCollections: CollectionMeta[]): string {
|
|
223
|
+
if (postCollections.length === 1) return `/${postCollections[0]!.slug}/new`
|
|
224
|
+
if (postCollections.length > 1) return '/posts/new'
|
|
225
|
+
return '/posts/types/new'
|
|
226
|
+
}
|
|
227
|
+
|
|
172
228
|
function collectionList(config: any): CollectionMeta[] {
|
|
173
229
|
if (!config?.collections) return []
|
|
174
230
|
const raw = config.collections
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -19,9 +19,12 @@ import {
|
|
|
19
19
|
PanelTop,
|
|
20
20
|
PanelBottom,
|
|
21
21
|
Layers,
|
|
22
|
+
LayoutGrid,
|
|
22
23
|
Code2,
|
|
23
24
|
Library,
|
|
24
25
|
KeyRound,
|
|
26
|
+
ListTree,
|
|
27
|
+
Menu as MenuIcon,
|
|
25
28
|
PanelLeftClose,
|
|
26
29
|
PanelLeftOpen,
|
|
27
30
|
} from 'lucide-react'
|
|
@@ -61,6 +64,9 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
61
64
|
book: BookOpen,
|
|
62
65
|
help: HelpCircle,
|
|
63
66
|
newspaper: Newspaper,
|
|
67
|
+
navigation: ListTree,
|
|
68
|
+
menu: MenuIcon,
|
|
69
|
+
'list-tree': ListTree,
|
|
64
70
|
PanelTop: PanelTop,
|
|
65
71
|
PanelBottom: PanelBottom,
|
|
66
72
|
Layers: Layers,
|
|
@@ -106,24 +112,45 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
* Children of the Posts parent. Same shape regardless of how many
|
|
116
|
+
* post-type collections are registered — Post Types are accessed via
|
|
117
|
+
* the `/posts/types` cards page, not as individual sidebar entries.
|
|
118
|
+
*/
|
|
119
|
+
const POSTS_CHILDREN: NavItem[] = [
|
|
120
|
+
{ path: '/posts', label: 'All Posts', icon: FileText },
|
|
121
|
+
{ path: '/posts/types', label: 'Post Types', icon: Layers },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Children of the Templates parent. Templates is its own top-level item
|
|
126
|
+
* (page building tools) — Saved Sections are reusable content blocks and
|
|
127
|
+
* Widgets are embeddable components. These are deliberately NOT children
|
|
128
|
+
* of Pages: Pages is content, Templates/Sections/Widgets are structure.
|
|
129
|
+
*/
|
|
130
|
+
const TEMPLATES_CHILDREN: NavItem[] = [
|
|
131
|
+
{ path: '/saved-sections', label: 'Saved Sections', icon: Library },
|
|
132
|
+
{ path: '/widgets', label: 'Widgets', icon: LayoutGrid },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Nav tree for the default (config-less) install. Dashboard sits on its
|
|
137
|
+
* own at the top, then a CONTENT group holding Pages (no children) above
|
|
138
|
+
* Posts (with All Posts + Post Types) and Templates (with Saved Sections
|
|
139
|
+
* + Widgets), followed by Media / Forms / SEO / Script Tags, then a
|
|
140
|
+
* SETTINGS group (Users / API Keys / Settings). The group ordering is
|
|
141
|
+
* meaningful — the renderer emits a section header the first time it
|
|
142
|
+
* sees a new group, so "CONTENT" renders once, above Pages.
|
|
115
143
|
*/
|
|
116
144
|
const defaultNavItems: NavItem[] = [
|
|
117
145
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
|
118
|
-
{ path: '/
|
|
146
|
+
{ path: '/pages', label: 'Pages', icon: File, group: 'Content' },
|
|
147
|
+
{ path: '/posts', label: 'Posts', icon: FileText, group: 'Content', children: POSTS_CHILDREN },
|
|
119
148
|
{
|
|
120
|
-
path: '/
|
|
121
|
-
label: '
|
|
122
|
-
icon:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{ path: '/page-templates', label: 'Templates', icon: Layers },
|
|
126
|
-
],
|
|
149
|
+
path: '/page-templates',
|
|
150
|
+
label: 'Templates',
|
|
151
|
+
icon: Layers,
|
|
152
|
+
group: 'Content',
|
|
153
|
+
children: TEMPLATES_CHILDREN,
|
|
127
154
|
},
|
|
128
155
|
{ path: '/media', label: 'Media', icon: Image, group: 'Content' },
|
|
129
156
|
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
@@ -207,6 +234,15 @@ interface NavRenderContext {
|
|
|
207
234
|
|
|
208
235
|
function isPathActive(currentPath: string, itemPath: string): boolean {
|
|
209
236
|
if (itemPath === '/') return currentPath === '/'
|
|
237
|
+
|
|
238
|
+
// Special case: /posts is a prefix of /posts/types — without this
|
|
239
|
+
// guard, "All Posts" (/posts) would stay highlighted while the user
|
|
240
|
+
// is on /posts/types or /posts/types/new. Treat /posts as exact-only
|
|
241
|
+
// when a sibling /posts/* route exists.
|
|
242
|
+
if (itemPath === '/posts') {
|
|
243
|
+
return currentPath === '/posts' || currentPath === '/posts/new'
|
|
244
|
+
}
|
|
245
|
+
|
|
210
246
|
// Use boundary match so /pages doesn't match /pages-something. Exact, or
|
|
211
247
|
// followed by a `/` separator.
|
|
212
248
|
return currentPath === itemPath || currentPath.startsWith(itemPath + '/')
|
|
@@ -236,10 +272,16 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
|
|
|
236
272
|
lastGroup = undefined
|
|
237
273
|
}
|
|
238
274
|
|
|
239
|
-
|
|
275
|
+
// Keys are namespaced because a parent and its first child can share
|
|
276
|
+
// the same path (e.g. the Posts parent and its "All Posts" child are
|
|
277
|
+
// both `/posts`). Using the raw path as the key collided and made
|
|
278
|
+
// React warn about — and risk dropping — duplicate siblings.
|
|
279
|
+
nodes.push(<NavLink key={`item:${item.path}`} item={item} ctx={ctx} />)
|
|
240
280
|
if (item.children && item.children.length > 0 && !ctx.collapsed) {
|
|
241
281
|
for (const child of item.children) {
|
|
242
|
-
nodes.push(
|
|
282
|
+
nodes.push(
|
|
283
|
+
<NavLink key={`child:${item.path}:${child.path}`} item={child} ctx={ctx} nested />,
|
|
284
|
+
)
|
|
243
285
|
}
|
|
244
286
|
}
|
|
245
287
|
}
|
|
@@ -315,24 +357,32 @@ export interface NavItem {
|
|
|
315
357
|
* destination (Media, Forms, SEO, Script Tags, Users, API Keys,
|
|
316
358
|
* Settings) is anchored under the right Content / Settings group.
|
|
317
359
|
*/
|
|
318
|
-
function buildNavItems(config: any): NavItem[] {
|
|
360
|
+
export function buildNavItems(config: any): NavItem[] {
|
|
319
361
|
if (!config?.collections) return defaultNavItems
|
|
320
362
|
|
|
321
363
|
const raw = config.collections
|
|
322
364
|
const collectionsList: any[] = Array.isArray(raw) ? raw : Object.values(raw)
|
|
323
365
|
const visible = collectionsList.filter((c) => !c.admin?.hidden)
|
|
324
366
|
|
|
367
|
+
// Treat `admin.group: 'Posts'` (case-insensitive) as a synonym for
|
|
368
|
+
// `type: 'post'` so consumers can tag arbitrary collections into
|
|
369
|
+
// the Posts area without changing the discriminator they already
|
|
370
|
+
// use elsewhere (sitemaps, schema.org, etc.).
|
|
371
|
+
const isPostsGroup = (c: any) =>
|
|
372
|
+
typeof c.admin?.group === 'string' && c.admin.group.toLowerCase() === 'posts'
|
|
325
373
|
const pages = visible.filter((c) => c.type === 'page' && !c.admin?.group)
|
|
326
|
-
const posts = visible.filter((c) => c.type === 'post'
|
|
374
|
+
const posts = visible.filter((c) => c.type === 'post' || isPostsGroup(c))
|
|
327
375
|
const otherUngrouped = visible.filter(
|
|
328
376
|
(c) => c.type !== 'page' && c.type !== 'post' && !c.admin?.group,
|
|
329
377
|
)
|
|
330
|
-
|
|
378
|
+
// Anything explicitly grouped that isn't a posts collection.
|
|
379
|
+
const grouped = visible.filter((c) => c.admin?.group && !isPostsGroup(c) && c.type !== 'post')
|
|
331
380
|
|
|
332
381
|
const items: NavItem[] = [{ path: '/', label: 'Dashboard', icon: LayoutDashboard }]
|
|
333
382
|
|
|
334
|
-
//
|
|
335
|
-
|
|
383
|
+
// Ungrouped collections that are neither page- nor post-type stay at
|
|
384
|
+
// the top level, directly under Dashboard, before the CONTENT group.
|
|
385
|
+
for (const collection of otherUngrouped) {
|
|
336
386
|
items.push({
|
|
337
387
|
label: collection.labels?.plural ?? collection.slug,
|
|
338
388
|
path: `/${collection.slug}`,
|
|
@@ -340,37 +390,59 @@ function buildNavItems(config: any): NavItem[] {
|
|
|
340
390
|
})
|
|
341
391
|
}
|
|
342
392
|
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
393
|
+
// CONTENT group. Everything below shares `group: 'Content'`, so the
|
|
394
|
+
// renderer emits the "CONTENT" header once — above Pages. Order:
|
|
395
|
+
// Pages → Posts → static content destinations (Media / Forms / SEO /
|
|
396
|
+
// Script Tags).
|
|
397
|
+
|
|
398
|
+
// Pages-style collections render as plain Content entries (no nested
|
|
399
|
+
// children). Saved Sections / Widgets now live under the dedicated
|
|
400
|
+
// Templates parent below, not under Pages.
|
|
401
|
+
pages.forEach((collection) => {
|
|
402
|
+
items.push({
|
|
351
403
|
label: collection.labels?.plural ?? collection.slug,
|
|
352
404
|
path: `/${collection.slug}`,
|
|
353
405
|
icon: resolveIcon(collection),
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
item.children = [
|
|
357
|
-
{ path: '/saved-sections', label: 'Saved Sections', icon: Library },
|
|
358
|
-
{ path: '/page-templates', label: 'Templates', icon: Layers },
|
|
359
|
-
]
|
|
360
|
-
}
|
|
361
|
-
items.push(item)
|
|
406
|
+
group: 'Content',
|
|
407
|
+
})
|
|
362
408
|
})
|
|
363
409
|
|
|
364
|
-
|
|
410
|
+
// Posts area — collapses every post-type collection under a single
|
|
411
|
+
// "Posts" parent. The aggregated table at /posts and the type cards
|
|
412
|
+
// at /posts/types are the entry points; individual post-type
|
|
413
|
+
// collections are not enumerated in the sidebar to avoid the
|
|
414
|
+
// duplicate-nav problem when a project has many types.
|
|
415
|
+
if (posts.length > 0) {
|
|
365
416
|
items.push({
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
icon:
|
|
417
|
+
path: '/posts',
|
|
418
|
+
label: 'Posts',
|
|
419
|
+
icon: FileText,
|
|
420
|
+
group: 'Content',
|
|
421
|
+
children: POSTS_CHILDREN,
|
|
369
422
|
})
|
|
370
423
|
}
|
|
371
424
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
425
|
+
// Templates — its own parent holding Saved Sections + Widgets (page
|
|
426
|
+
// building structure, distinct from Pages content).
|
|
427
|
+
items.push({
|
|
428
|
+
path: '/page-templates',
|
|
429
|
+
label: 'Templates',
|
|
430
|
+
icon: Layers,
|
|
431
|
+
group: 'Content',
|
|
432
|
+
children: TEMPLATES_CHILDREN,
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// Standard Content destinations (always present — they map to CMS
|
|
436
|
+
// features, not collections).
|
|
437
|
+
items.push(
|
|
438
|
+
{ path: '/media', label: 'Media', icon: Image, group: 'Content' },
|
|
439
|
+
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
440
|
+
{ path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
|
|
441
|
+
{ path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
// Config-supplied custom groups land after the standard Content block
|
|
445
|
+
// and before Settings. Each renders under its own header.
|
|
374
446
|
const groupBuckets = new Map<string, NavItem[]>()
|
|
375
447
|
for (const col of grouped) {
|
|
376
448
|
const group = col.admin.group as string
|
|
@@ -385,14 +457,8 @@ function buildNavItems(config: any): NavItem[] {
|
|
|
385
457
|
}
|
|
386
458
|
for (const bucket of groupBuckets.values()) items.push(...bucket)
|
|
387
459
|
|
|
388
|
-
//
|
|
389
|
-
// present, regardless of which collections the admin configures,
|
|
390
|
-
// because they map to CMS features (not collections).
|
|
460
|
+
// Settings destinations — always last.
|
|
391
461
|
items.push(
|
|
392
|
-
{ path: '/media', label: 'Media', icon: Image, group: 'Content' },
|
|
393
|
-
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
394
|
-
{ path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
|
|
395
|
-
{ path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
|
|
396
462
|
{ path: '/users', label: 'Users', icon: Users, group: 'Settings' },
|
|
397
463
|
{ path: '/api-keys', label: 'API Keys', icon: KeyRound, group: 'Settings' },
|
|
398
464
|
{ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Editor service — data layer for the flat, section-based page
|
|
3
|
+
* editor (`/pages/:id/edit`).
|
|
4
|
+
*
|
|
5
|
+
* A page is a document in the `pages` collection. The editor reads and
|
|
6
|
+
* writes a single JSON field, `data.sections`, holding an ordered list of
|
|
7
|
+
* `PageSection`s. Save draft = `PUT` the sections (+ title/slug/SEO) with
|
|
8
|
+
* no status change; publish = the same payload plus `status: 'PUBLISHED'`.
|
|
9
|
+
*
|
|
10
|
+
* Persistence reuses the existing collection document API, so there is no
|
|
11
|
+
* new endpoint and no Prisma migration — `sections` is declared as a
|
|
12
|
+
* `type: 'json'` field on the `pages` collection so the field-access layer
|
|
13
|
+
* doesn't strip it on write.
|
|
14
|
+
*
|
|
15
|
+
* The section mutation helpers (`addSection`, `moveSection`, …) are pure
|
|
16
|
+
* functions over `PageSection[]` so the editor state and the unit tests
|
|
17
|
+
* share one implementation.
|
|
18
|
+
*/
|
|
19
|
+
import { cmsApi } from './api.js'
|
|
20
|
+
import {
|
|
21
|
+
getSectionType,
|
|
22
|
+
createSection,
|
|
23
|
+
addSection,
|
|
24
|
+
removeSection,
|
|
25
|
+
duplicateSection,
|
|
26
|
+
toggleSectionVisibility,
|
|
27
|
+
moveSection,
|
|
28
|
+
reorderSections,
|
|
29
|
+
updateSectionContent,
|
|
30
|
+
updateSectionSettings,
|
|
31
|
+
updateSectionMeta,
|
|
32
|
+
coerceSections as coerceSectionsCore,
|
|
33
|
+
validateSectionContent,
|
|
34
|
+
type PageSection,
|
|
35
|
+
} from '@actuate-media/cms-core/page-sections'
|
|
36
|
+
|
|
37
|
+
export type { PageSection } from '@actuate-media/cms-core/page-sections'
|
|
38
|
+
|
|
39
|
+
// Re-export the pure section helpers so existing editor imports
|
|
40
|
+
// (`../../lib/page-editor-service.js`) keep working after the move to core.
|
|
41
|
+
export {
|
|
42
|
+
createSection,
|
|
43
|
+
addSection,
|
|
44
|
+
removeSection,
|
|
45
|
+
duplicateSection,
|
|
46
|
+
toggleSectionVisibility,
|
|
47
|
+
moveSection,
|
|
48
|
+
reorderSections,
|
|
49
|
+
updateSectionContent,
|
|
50
|
+
updateSectionSettings,
|
|
51
|
+
updateSectionMeta,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
|
|
55
|
+
|
|
56
|
+
export const PAGES_COLLECTION = 'pages'
|
|
57
|
+
|
|
58
|
+
export interface EditorPage {
|
|
59
|
+
id: string | null
|
|
60
|
+
title: string
|
|
61
|
+
slug: string
|
|
62
|
+
path: string
|
|
63
|
+
status: PageStatus
|
|
64
|
+
sections: PageSection[]
|
|
65
|
+
seoTitle: string
|
|
66
|
+
seoDescription: string
|
|
67
|
+
publishedAt: string | null
|
|
68
|
+
updatedAt: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ValidationResult {
|
|
72
|
+
/** blocking issues — publish is disabled while any exist */
|
|
73
|
+
errors: string[]
|
|
74
|
+
/** non-blocking advisories (e.g. SEO) */
|
|
75
|
+
warnings: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Validation ──────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function isBlank(v: unknown): boolean {
|
|
81
|
+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate a page for save/publish. `forPublish` turns on the stricter
|
|
86
|
+
* rules (at least one visible section, required section fields, valid
|
|
87
|
+
* button URLs, image alt text). SEO gaps are surfaced as warnings.
|
|
88
|
+
*
|
|
89
|
+
* Per-section rules are delegated to the shared `validateSectionContent`
|
|
90
|
+
* in `@actuate-media/cms-core/page-sections`, so the editor and the
|
|
91
|
+
* server/MCP enforce identical content rules. Page-level rules
|
|
92
|
+
* (title/path, "at least one visible section", SEO) stay here because
|
|
93
|
+
* they operate on the whole `EditorPage`.
|
|
94
|
+
*/
|
|
95
|
+
export function validatePage(page: EditorPage, forPublish = false): ValidationResult {
|
|
96
|
+
const errors: string[] = []
|
|
97
|
+
const warnings: string[] = []
|
|
98
|
+
|
|
99
|
+
if (isBlank(page.title)) errors.push('Page title is required.')
|
|
100
|
+
if (isBlank(page.path)) errors.push('Page path is required.')
|
|
101
|
+
|
|
102
|
+
if (forPublish) {
|
|
103
|
+
const visible = page.sections.filter((s) => s.visible)
|
|
104
|
+
if (visible.length === 0) {
|
|
105
|
+
errors.push('Add at least one visible section before publishing.')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const section of visible) {
|
|
109
|
+
const def = getSectionType(section.sectionType)
|
|
110
|
+
const label = section.name || def?.name || 'Section'
|
|
111
|
+
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
112
|
+
for (const issue of result.errors) {
|
|
113
|
+
errors.push(`${label}: ${issue.message}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isBlank(page.seoTitle)) warnings.push('Add an SEO title to improve search results.')
|
|
118
|
+
if (isBlank(page.seoDescription)) {
|
|
119
|
+
warnings.push('Add an SEO description to improve search results.')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { errors, warnings }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── API row mapping ─────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
interface DocumentApiRow {
|
|
129
|
+
id: string
|
|
130
|
+
title: string | null
|
|
131
|
+
slug: string | null
|
|
132
|
+
status: PageStatus
|
|
133
|
+
publishedAt: string | null
|
|
134
|
+
updatedAt: string | null
|
|
135
|
+
data: Record<string, unknown> | null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function rowToEditorPage(doc: DocumentApiRow): EditorPage {
|
|
139
|
+
const data = doc.data ?? {}
|
|
140
|
+
const slug = doc.slug ?? ''
|
|
141
|
+
return {
|
|
142
|
+
id: doc.id,
|
|
143
|
+
title: doc.title ?? '',
|
|
144
|
+
slug,
|
|
145
|
+
path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
|
|
146
|
+
status: doc.status ?? 'DRAFT',
|
|
147
|
+
sections: coerceSectionsCore(data.sections),
|
|
148
|
+
seoTitle: typeof data.metaTitle === 'string' ? data.metaTitle : '',
|
|
149
|
+
seoDescription: typeof data.metaDescription === 'string' ? data.metaDescription : '',
|
|
150
|
+
publishedAt: doc.publishedAt ?? null,
|
|
151
|
+
updatedAt: doc.updatedAt ?? null,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── API calls ───────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export async function fetchPageForEditor(pageId: string): Promise<EditorPage> {
|
|
158
|
+
const res = await cmsApi<DocumentApiRow>(`/collections/${PAGES_COLLECTION}/${pageId}`)
|
|
159
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to load page')
|
|
160
|
+
return rowToEditorPage(res.data)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface PageWritePayload {
|
|
164
|
+
title: string
|
|
165
|
+
slug: string
|
|
166
|
+
path: string
|
|
167
|
+
sections: PageSection[]
|
|
168
|
+
metaTitle: string
|
|
169
|
+
metaDescription: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string {
|
|
173
|
+
const payload: PageWritePayload & Record<string, unknown> = {
|
|
174
|
+
title: page.title,
|
|
175
|
+
slug: page.slug,
|
|
176
|
+
path: page.path,
|
|
177
|
+
sections: page.sections,
|
|
178
|
+
metaTitle: page.seoTitle,
|
|
179
|
+
metaDescription: page.seoDescription,
|
|
180
|
+
...extra,
|
|
181
|
+
}
|
|
182
|
+
return JSON.stringify(payload)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Create a new page document, returning the new id. */
|
|
186
|
+
export async function createPage(page: EditorPage): Promise<EditorPage> {
|
|
187
|
+
const res = await cmsApi<DocumentApiRow>(`/collections/${PAGES_COLLECTION}`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
body: toWriteBody(page),
|
|
190
|
+
})
|
|
191
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to create page')
|
|
192
|
+
return rowToEditorPage(res.data)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Save the current draft (no status change). */
|
|
196
|
+
export async function savePageDraft(page: EditorPage): Promise<EditorPage> {
|
|
197
|
+
if (!page.id) return createPage(page)
|
|
198
|
+
const res = await cmsApi<DocumentApiRow>(`/collections/${PAGES_COLLECTION}/${page.id}`, {
|
|
199
|
+
method: 'PUT',
|
|
200
|
+
body: toWriteBody(page),
|
|
201
|
+
})
|
|
202
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save draft')
|
|
203
|
+
return rowToEditorPage(res.data)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Publish: persist the latest content and flip status to PUBLISHED. */
|
|
207
|
+
export async function publishPage(page: EditorPage): Promise<EditorPage> {
|
|
208
|
+
const endpoint = page.id
|
|
209
|
+
? `/collections/${PAGES_COLLECTION}/${page.id}`
|
|
210
|
+
: `/collections/${PAGES_COLLECTION}`
|
|
211
|
+
const res = await cmsApi<DocumentApiRow>(endpoint, {
|
|
212
|
+
method: page.id ? 'PUT' : 'POST',
|
|
213
|
+
body: toWriteBody(page, { status: 'PUBLISHED' }),
|
|
214
|
+
})
|
|
215
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to publish page')
|
|
216
|
+
return rowToEditorPage(res.data)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A blank page scaffold for `/pages/new`. */
|
|
220
|
+
export function emptyPage(): EditorPage {
|
|
221
|
+
return {
|
|
222
|
+
id: null,
|
|
223
|
+
title: '',
|
|
224
|
+
slug: '',
|
|
225
|
+
path: '',
|
|
226
|
+
status: 'DRAFT',
|
|
227
|
+
sections: [],
|
|
228
|
+
seoTitle: '',
|
|
229
|
+
seoDescription: '',
|
|
230
|
+
publishedAt: null,
|
|
231
|
+
updatedAt: null,
|
|
232
|
+
}
|
|
233
|
+
}
|