@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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post Editor service — data layer for the flat, section-based Post editor
|
|
3
|
+
* (`/posts/:type/:id/edit`) and the per-type Post Template editor
|
|
4
|
+
* (`/posts/types/:slug/template`).
|
|
5
|
+
*
|
|
6
|
+
* A post is a document in its post-type collection (e.g. `blog`). The editor
|
|
7
|
+
* reads/writes the post's fields (title, slug, excerpt, featured image,
|
|
8
|
+
* body, SEO) plus a `data.sections` JSON list of `PageSection`s — the same
|
|
9
|
+
* flat model the Page Editor uses. New posts inherit their section list from
|
|
10
|
+
* the post type's template.
|
|
11
|
+
*
|
|
12
|
+
* A template is a single document per post type in the hidden
|
|
13
|
+
* `post-templates` collection, matched by `slug` == the post-type slug. It
|
|
14
|
+
* stores the design-driven header layout (`data.header`) and the default
|
|
15
|
+
* section list (`data.sections`).
|
|
16
|
+
*
|
|
17
|
+
* Persistence reuses the existing collection document API, so there is no
|
|
18
|
+
* new endpoint and no Prisma migration — `sections` is declared as `json`
|
|
19
|
+
* on each post-type collection (and `header`/`sections` on `post-templates`)
|
|
20
|
+
* so the field-access layer doesn't strip them on write.
|
|
21
|
+
*/
|
|
22
|
+
import { cmsApi } from './api.js'
|
|
23
|
+
import {
|
|
24
|
+
getSectionType,
|
|
25
|
+
createSection,
|
|
26
|
+
addSection,
|
|
27
|
+
removeSection,
|
|
28
|
+
duplicateSection,
|
|
29
|
+
toggleSectionVisibility,
|
|
30
|
+
moveSection,
|
|
31
|
+
reorderSections,
|
|
32
|
+
updateSectionContent,
|
|
33
|
+
updateSectionSettings,
|
|
34
|
+
updateSectionMeta,
|
|
35
|
+
coerceSections,
|
|
36
|
+
coercePostHeader,
|
|
37
|
+
defaultPostHeader,
|
|
38
|
+
validateSectionContent,
|
|
39
|
+
type PageSection,
|
|
40
|
+
type PostHeaderConfig,
|
|
41
|
+
} from '@actuate-media/cms-core/page-sections'
|
|
42
|
+
|
|
43
|
+
export type { PageSection, PostHeaderConfig } from '@actuate-media/cms-core/page-sections'
|
|
44
|
+
|
|
45
|
+
// Re-export the pure section helpers so the post editor imports them from
|
|
46
|
+
// one place (mirrors page-editor-service).
|
|
47
|
+
export {
|
|
48
|
+
createSection,
|
|
49
|
+
addSection,
|
|
50
|
+
removeSection,
|
|
51
|
+
duplicateSection,
|
|
52
|
+
toggleSectionVisibility,
|
|
53
|
+
moveSection,
|
|
54
|
+
reorderSections,
|
|
55
|
+
updateSectionContent,
|
|
56
|
+
updateSectionSettings,
|
|
57
|
+
updateSectionMeta,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PostStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
|
|
61
|
+
|
|
62
|
+
export const TEMPLATES_COLLECTION = 'post-templates'
|
|
63
|
+
|
|
64
|
+
export interface EditorPost {
|
|
65
|
+
id: string | null
|
|
66
|
+
/** the post-type collection slug (e.g. "blog") */
|
|
67
|
+
postType: string
|
|
68
|
+
title: string
|
|
69
|
+
slug: string
|
|
70
|
+
excerpt: string
|
|
71
|
+
featuredImage: string
|
|
72
|
+
body: string
|
|
73
|
+
category: string
|
|
74
|
+
status: PostStatus
|
|
75
|
+
publishDate: string | null
|
|
76
|
+
sections: PageSection[]
|
|
77
|
+
seoTitle: string
|
|
78
|
+
seoDescription: string
|
|
79
|
+
publishedAt: string | null
|
|
80
|
+
updatedAt: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EditorPostTemplate {
|
|
84
|
+
/** template document id (null when the type has no saved template yet) */
|
|
85
|
+
docId: string | null
|
|
86
|
+
postType: string
|
|
87
|
+
header: PostHeaderConfig
|
|
88
|
+
sections: PageSection[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ValidationResult {
|
|
92
|
+
errors: string[]
|
|
93
|
+
warnings: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Validation ──────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function isBlank(v: unknown): boolean {
|
|
99
|
+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate a post for save/publish. Section rules are delegated to the
|
|
104
|
+
* shared `validateSectionContent` so the editor and the server/MCP enforce
|
|
105
|
+
* identical content rules; post-level rules (title/slug, SEO) live here.
|
|
106
|
+
*/
|
|
107
|
+
export function validatePost(post: EditorPost, forPublish = false): ValidationResult {
|
|
108
|
+
const errors: string[] = []
|
|
109
|
+
const warnings: string[] = []
|
|
110
|
+
|
|
111
|
+
if (isBlank(post.title)) errors.push('Post title is required.')
|
|
112
|
+
if (isBlank(post.slug)) errors.push('Post slug is required.')
|
|
113
|
+
|
|
114
|
+
if (forPublish) {
|
|
115
|
+
const visible = post.sections.filter((s) => s.visible)
|
|
116
|
+
for (const section of visible) {
|
|
117
|
+
const def = getSectionType(section.sectionType)
|
|
118
|
+
const label = section.name || def?.name || 'Section'
|
|
119
|
+
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
120
|
+
for (const issue of result.errors) errors.push(`${label}: ${issue.message}`)
|
|
121
|
+
for (const issue of result.warnings) warnings.push(`${label}: ${issue.message}`)
|
|
122
|
+
}
|
|
123
|
+
if (isBlank(post.seoTitle)) warnings.push('Add an SEO title to improve search results.')
|
|
124
|
+
if (isBlank(post.seoDescription)) {
|
|
125
|
+
warnings.push('Add an SEO description to improve search results.')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { errors, warnings }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Validate a template for save (section content only — no post fields). */
|
|
133
|
+
export function validateTemplate(template: EditorPostTemplate): ValidationResult {
|
|
134
|
+
const errors: string[] = []
|
|
135
|
+
const warnings: string[] = []
|
|
136
|
+
for (const section of template.sections.filter((s) => s.visible)) {
|
|
137
|
+
const def = getSectionType(section.sectionType)
|
|
138
|
+
const label = section.name || def?.name || 'Section'
|
|
139
|
+
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
140
|
+
for (const issue of result.errors) errors.push(`${label}: ${issue.message}`)
|
|
141
|
+
for (const issue of result.warnings) warnings.push(`${label}: ${issue.message}`)
|
|
142
|
+
}
|
|
143
|
+
return { errors, warnings }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── API row mapping ─────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
interface DocumentApiRow {
|
|
149
|
+
id: string
|
|
150
|
+
collection?: string
|
|
151
|
+
title: string | null
|
|
152
|
+
slug: string | null
|
|
153
|
+
status: PostStatus
|
|
154
|
+
publishedAt: string | null
|
|
155
|
+
scheduledAt?: string | null
|
|
156
|
+
updatedAt: string | null
|
|
157
|
+
data: Record<string, unknown> | null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function dataStr(data: Record<string, unknown>, key: string): string {
|
|
161
|
+
const v = data[key]
|
|
162
|
+
return typeof v === 'string' ? v : ''
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
|
|
166
|
+
const data = doc.data ?? {}
|
|
167
|
+
return {
|
|
168
|
+
id: doc.id,
|
|
169
|
+
postType,
|
|
170
|
+
title: doc.title ?? dataStr(data, 'title'),
|
|
171
|
+
slug: doc.slug ?? dataStr(data, 'slug'),
|
|
172
|
+
excerpt: dataStr(data, 'excerpt'),
|
|
173
|
+
featuredImage: dataStr(data, 'featuredImage'),
|
|
174
|
+
body: dataStr(data, 'body'),
|
|
175
|
+
category: dataStr(data, 'category'),
|
|
176
|
+
status: doc.status ?? 'DRAFT',
|
|
177
|
+
publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
|
|
178
|
+
sections: coerceSections(data.sections),
|
|
179
|
+
seoTitle: dataStr(data, 'metaTitle'),
|
|
180
|
+
seoDescription: dataStr(data, 'metaDescription'),
|
|
181
|
+
publishedAt: doc.publishedAt ?? null,
|
|
182
|
+
updatedAt: doc.updatedAt ?? null,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Templates ───────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
interface ListEnvelope {
|
|
189
|
+
docs: DocumentApiRow[]
|
|
190
|
+
total: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Load a post type's template. Looks up the single `post-templates` document
|
|
195
|
+
* whose slug matches the post type. Returns an unsaved default (centered
|
|
196
|
+
* header, empty section list) when the type has no template yet.
|
|
197
|
+
*/
|
|
198
|
+
export async function fetchTemplateForEditor(postType: string): Promise<EditorPostTemplate> {
|
|
199
|
+
const res = await cmsApi<ListEnvelope>(`/collections/${TEMPLATES_COLLECTION}?pageSize=200`)
|
|
200
|
+
const docs = res.data?.docs ?? []
|
|
201
|
+
const match = docs.find((d) => (d.slug ?? d.data?.slug) === postType)
|
|
202
|
+
if (!match) {
|
|
203
|
+
return { docId: null, postType, header: defaultPostHeader(), sections: [] }
|
|
204
|
+
}
|
|
205
|
+
const data = match.data ?? {}
|
|
206
|
+
return {
|
|
207
|
+
docId: match.id,
|
|
208
|
+
postType,
|
|
209
|
+
header: coercePostHeader(data.header),
|
|
210
|
+
sections: coerceSections(data.sections),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function templateWriteBody(template: EditorPostTemplate, extra?: Record<string, unknown>): string {
|
|
215
|
+
return JSON.stringify({
|
|
216
|
+
title: `${template.postType} template`,
|
|
217
|
+
slug: template.postType,
|
|
218
|
+
postType: template.postType,
|
|
219
|
+
header: template.header,
|
|
220
|
+
sections: template.sections,
|
|
221
|
+
...extra,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Persist a post type's template (creates the doc on first save). */
|
|
226
|
+
export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
|
|
227
|
+
const endpoint = template.docId
|
|
228
|
+
? `/collections/${TEMPLATES_COLLECTION}/${template.docId}`
|
|
229
|
+
: `/collections/${TEMPLATES_COLLECTION}`
|
|
230
|
+
const res = await cmsApi<DocumentApiRow>(endpoint, {
|
|
231
|
+
method: template.docId ? 'PUT' : 'POST',
|
|
232
|
+
body: templateWriteBody(template, { status: 'PUBLISHED' }),
|
|
233
|
+
})
|
|
234
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
|
|
235
|
+
const data = res.data.data ?? {}
|
|
236
|
+
return {
|
|
237
|
+
docId: res.data.id,
|
|
238
|
+
postType: template.postType,
|
|
239
|
+
header: coercePostHeader(data.header),
|
|
240
|
+
sections: coerceSections(data.sections),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Posts ───────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
export async function fetchPostForEditor(postType: string, id: string): Promise<EditorPost> {
|
|
247
|
+
const res = await cmsApi<DocumentApiRow>(`/collections/${encodeURIComponent(postType)}/${id}`)
|
|
248
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to load post')
|
|
249
|
+
return rowToEditorPost(postType, res.data)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface PostWritePayload {
|
|
253
|
+
title: string
|
|
254
|
+
slug: string
|
|
255
|
+
excerpt: string
|
|
256
|
+
featuredImage: string
|
|
257
|
+
body: string
|
|
258
|
+
category: string
|
|
259
|
+
publishDate: string | null
|
|
260
|
+
sections: PageSection[]
|
|
261
|
+
metaTitle: string
|
|
262
|
+
metaDescription: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string {
|
|
266
|
+
const payload: PostWritePayload & Record<string, unknown> = {
|
|
267
|
+
title: post.title,
|
|
268
|
+
slug: post.slug,
|
|
269
|
+
excerpt: post.excerpt,
|
|
270
|
+
featuredImage: post.featuredImage,
|
|
271
|
+
body: post.body,
|
|
272
|
+
category: post.category,
|
|
273
|
+
publishDate: post.publishDate,
|
|
274
|
+
sections: post.sections,
|
|
275
|
+
metaTitle: post.seoTitle,
|
|
276
|
+
metaDescription: post.seoDescription,
|
|
277
|
+
...extra,
|
|
278
|
+
}
|
|
279
|
+
return JSON.stringify(payload)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function createPost(post: EditorPost): Promise<EditorPost> {
|
|
283
|
+
const res = await cmsApi<DocumentApiRow>(`/collections/${encodeURIComponent(post.postType)}`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
body: toWriteBody(post),
|
|
286
|
+
})
|
|
287
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to create post')
|
|
288
|
+
return rowToEditorPost(post.postType, res.data)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function savePostDraft(post: EditorPost): Promise<EditorPost> {
|
|
292
|
+
if (!post.id) return createPost(post)
|
|
293
|
+
const res = await cmsApi<DocumentApiRow>(
|
|
294
|
+
`/collections/${encodeURIComponent(post.postType)}/${post.id}`,
|
|
295
|
+
{ method: 'PUT', body: toWriteBody(post) },
|
|
296
|
+
)
|
|
297
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save draft')
|
|
298
|
+
return rowToEditorPost(post.postType, res.data)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function publishPost(post: EditorPost): Promise<EditorPost> {
|
|
302
|
+
const endpoint = post.id
|
|
303
|
+
? `/collections/${encodeURIComponent(post.postType)}/${post.id}`
|
|
304
|
+
: `/collections/${encodeURIComponent(post.postType)}`
|
|
305
|
+
const res = await cmsApi<DocumentApiRow>(endpoint, {
|
|
306
|
+
method: post.id ? 'PUT' : 'POST',
|
|
307
|
+
body: toWriteBody(post, { status: 'PUBLISHED' }),
|
|
308
|
+
})
|
|
309
|
+
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to publish post')
|
|
310
|
+
return rowToEditorPost(post.postType, res.data)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A blank post scaffold seeded with the post type's template sections. */
|
|
314
|
+
export async function emptyPostFromTemplate(postType: string): Promise<EditorPost> {
|
|
315
|
+
let sections: PageSection[] = []
|
|
316
|
+
try {
|
|
317
|
+
const template = await fetchTemplateForEditor(postType)
|
|
318
|
+
// Clone with fresh ids so the post owns its own sections, decoupled from
|
|
319
|
+
// the template (editing the post never mutates the type template).
|
|
320
|
+
sections = template.sections.map((s) => ({ ...s, id: createSection(s.sectionType).id }))
|
|
321
|
+
} catch {
|
|
322
|
+
// No template / fetch failure → start from an empty section list.
|
|
323
|
+
sections = []
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
id: null,
|
|
327
|
+
postType,
|
|
328
|
+
title: '',
|
|
329
|
+
slug: '',
|
|
330
|
+
excerpt: '',
|
|
331
|
+
featuredImage: '',
|
|
332
|
+
body: '',
|
|
333
|
+
category: '',
|
|
334
|
+
status: 'DRAFT',
|
|
335
|
+
publishDate: null,
|
|
336
|
+
sections,
|
|
337
|
+
seoTitle: '',
|
|
338
|
+
seoDescription: '',
|
|
339
|
+
publishedAt: null,
|
|
340
|
+
updatedAt: null,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posts service — thin client over the existing REST endpoints,
|
|
3
|
+
* shaped around the data the Posts admin area needs.
|
|
4
|
+
*
|
|
5
|
+
* V1 strategy: aggregate across post-typed collections client-side
|
|
6
|
+
* (one `/collections/:slug` call per type) and join authors via
|
|
7
|
+
* `/users`. Each view component pulls from `fetchPosts()` so when
|
|
8
|
+
* a server-side `/api/cms/posts` endpoint lands later, only this
|
|
9
|
+
* file changes — callers consume the same `Post[]` / `PostType[]`
|
|
10
|
+
* shapes.
|
|
11
|
+
*
|
|
12
|
+
* Why not a single `/collections/all` call?
|
|
13
|
+
* - Each collection has its own access-control check, so listing
|
|
14
|
+
* across collections must respect per-collection `read` rules.
|
|
15
|
+
* The existing endpoint enforces this per-call; mixing them
|
|
16
|
+
* server-side would need a new auth boundary.
|
|
17
|
+
* - V1 traffic is small (the seed is 15 rows); a few extra
|
|
18
|
+
* parallel requests are well under the rate-limit budget.
|
|
19
|
+
*
|
|
20
|
+
* Promote to a single endpoint when:
|
|
21
|
+
* - Any post-type collection grows past ~5k rows, OR
|
|
22
|
+
* - Page-load latency exceeds 300ms in the admin metrics.
|
|
23
|
+
*/
|
|
24
|
+
import { cmsApi } from './api.js'
|
|
25
|
+
|
|
26
|
+
export type PostStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
|
|
27
|
+
|
|
28
|
+
export interface PostType {
|
|
29
|
+
id: string // == slug
|
|
30
|
+
slug: string
|
|
31
|
+
name: string // plural label
|
|
32
|
+
singularName: string
|
|
33
|
+
description: string | null
|
|
34
|
+
urlBase: string
|
|
35
|
+
accentColor: string | null
|
|
36
|
+
icon: string | null
|
|
37
|
+
postCount: number
|
|
38
|
+
fieldCount: number
|
|
39
|
+
active: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PostAuthor {
|
|
43
|
+
id: string
|
|
44
|
+
name: string
|
|
45
|
+
initials: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Post {
|
|
49
|
+
id: string
|
|
50
|
+
title: string
|
|
51
|
+
slug: string
|
|
52
|
+
postTypeId: string
|
|
53
|
+
postTypeName: string
|
|
54
|
+
postTypeAccentColor: string | null
|
|
55
|
+
authorId: string
|
|
56
|
+
authorName: string
|
|
57
|
+
authorInitials: string
|
|
58
|
+
status: PostStatus
|
|
59
|
+
publishDate: string | null
|
|
60
|
+
updatedDate: string
|
|
61
|
+
seoScore: number | null
|
|
62
|
+
createdAt: string
|
|
63
|
+
updatedAt: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PostsQuery {
|
|
67
|
+
search?: string
|
|
68
|
+
postType?: string | 'all'
|
|
69
|
+
status?: PostStatus | 'all'
|
|
70
|
+
author?: string | 'all'
|
|
71
|
+
sortBy?: 'title' | 'type' | 'author' | 'status' | 'date' | 'seo'
|
|
72
|
+
sortDirection?: 'asc' | 'desc'
|
|
73
|
+
page?: number
|
|
74
|
+
pageSize?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PostsResult {
|
|
78
|
+
posts: Post[]
|
|
79
|
+
total: number
|
|
80
|
+
page: number
|
|
81
|
+
pageSize: number
|
|
82
|
+
types: PostType[]
|
|
83
|
+
authors: PostAuthor[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Collections → Post Types ────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
interface CollectionApiRow {
|
|
89
|
+
slug: string
|
|
90
|
+
labels: { singular: string; plural: string }
|
|
91
|
+
type: string
|
|
92
|
+
urlPrefix: string
|
|
93
|
+
description: string | null
|
|
94
|
+
icon: string | null
|
|
95
|
+
color: string | null
|
|
96
|
+
group: string | null
|
|
97
|
+
hidden: boolean
|
|
98
|
+
fieldCount: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isPostsCollection(c: CollectionApiRow): boolean {
|
|
102
|
+
return c.type === 'post' || (typeof c.group === 'string' && c.group.toLowerCase() === 'posts')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchPostCollections(): Promise<CollectionApiRow[]> {
|
|
106
|
+
const res = await cmsApi<CollectionApiRow[]>('/collections')
|
|
107
|
+
if (res.error || !res.data) return []
|
|
108
|
+
return res.data.filter((c) => !c.hidden && isPostsCollection(c))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function fetchPostTypes(): Promise<PostType[]> {
|
|
112
|
+
const cols = await fetchPostCollections()
|
|
113
|
+
// Counts are filled in by `fetchPosts` so the cards / tabs reflect
|
|
114
|
+
// the same numbers users see in the table. When called from a page
|
|
115
|
+
// that doesn't load posts (the New Post picker), the count stays
|
|
116
|
+
// at 0 until the next fetchPosts call.
|
|
117
|
+
return cols.map(collectionToPostType)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function collectionToPostType(c: CollectionApiRow, count = 0): PostType {
|
|
121
|
+
return {
|
|
122
|
+
id: c.slug,
|
|
123
|
+
slug: c.slug,
|
|
124
|
+
name: c.labels.plural,
|
|
125
|
+
singularName: c.labels.singular,
|
|
126
|
+
description: c.description ?? null,
|
|
127
|
+
urlBase: `/${c.urlPrefix || c.slug}`,
|
|
128
|
+
accentColor: c.color ?? null,
|
|
129
|
+
icon: c.icon ?? null,
|
|
130
|
+
postCount: count,
|
|
131
|
+
fieldCount: c.fieldCount,
|
|
132
|
+
active: true,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Users → Authors ─────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
interface UserApiRow {
|
|
139
|
+
id: string
|
|
140
|
+
name?: string | null
|
|
141
|
+
email: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function initialsFromName(name: string | null | undefined, email: string): string {
|
|
145
|
+
if (name && name.trim().length > 0) {
|
|
146
|
+
const parts = name.trim().split(/\s+/)
|
|
147
|
+
const first = parts[0]?.[0] ?? ''
|
|
148
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
|
|
149
|
+
return (first + last).toUpperCase() || (email[0] ?? '?').toUpperCase()
|
|
150
|
+
}
|
|
151
|
+
return (email[0] ?? '?').toUpperCase()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function fetchAuthorsMap(): Promise<Map<string, PostAuthor>> {
|
|
155
|
+
const res = await cmsApi<{ users: UserApiRow[] } | UserApiRow[]>('/users?pageSize=200')
|
|
156
|
+
const list: UserApiRow[] = Array.isArray(res.data)
|
|
157
|
+
? res.data
|
|
158
|
+
: ((res.data as { users?: UserApiRow[] } | null)?.users ?? [])
|
|
159
|
+
const map = new Map<string, PostAuthor>()
|
|
160
|
+
for (const u of list) {
|
|
161
|
+
map.set(u.id, {
|
|
162
|
+
id: u.id,
|
|
163
|
+
name: u.name?.trim() || u.email,
|
|
164
|
+
initials: initialsFromName(u.name, u.email),
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
return map
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Posts ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
interface DocumentApiRow {
|
|
173
|
+
id: string
|
|
174
|
+
collection: string
|
|
175
|
+
title: string | null
|
|
176
|
+
slug: string | null
|
|
177
|
+
status: PostStatus
|
|
178
|
+
createdById: string | null
|
|
179
|
+
updatedById: string | null
|
|
180
|
+
publishedAt: string | null
|
|
181
|
+
scheduledAt: string | null
|
|
182
|
+
createdAt: string
|
|
183
|
+
updatedAt: string
|
|
184
|
+
data: Record<string, unknown> | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractSeoScore(data: Record<string, unknown> | null): number | null {
|
|
188
|
+
if (!data) return null
|
|
189
|
+
const candidates = ['seoScore', 'seo_score', 'metaScore']
|
|
190
|
+
for (const k of candidates) {
|
|
191
|
+
const v = data[k]
|
|
192
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pickDisplayDate(d: DocumentApiRow): string {
|
|
198
|
+
if (d.status === 'SCHEDULED' && d.scheduledAt) return d.scheduledAt
|
|
199
|
+
if (d.status === 'PUBLISHED' && d.publishedAt) return d.publishedAt
|
|
200
|
+
return d.updatedAt
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface ListEnvelope {
|
|
204
|
+
docs: DocumentApiRow[]
|
|
205
|
+
total: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
|
|
209
|
+
const cols = await fetchPostCollections()
|
|
210
|
+
const authorsMap = await fetchAuthorsMap()
|
|
211
|
+
|
|
212
|
+
// Pull every doc for each post type (cap at 500 to avoid runaway
|
|
213
|
+
// requests; pagination + filtering still happens client-side over
|
|
214
|
+
// this set). When a type grows past 500 docs we'll need the
|
|
215
|
+
// server-side aggregate endpoint described in this file's header.
|
|
216
|
+
const perTypeRaw = await Promise.all(
|
|
217
|
+
cols.map(async (c) => {
|
|
218
|
+
const res = await cmsApi<ListEnvelope>(
|
|
219
|
+
`/collections/${encodeURIComponent(c.slug)}?pageSize=500&sort=updatedAt&order=desc`,
|
|
220
|
+
)
|
|
221
|
+
const docs = (res.data?.docs ?? []) as DocumentApiRow[]
|
|
222
|
+
return { col: c, docs }
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
// Map docs → Post objects.
|
|
227
|
+
const allPosts: Post[] = []
|
|
228
|
+
for (const { col, docs } of perTypeRaw) {
|
|
229
|
+
for (const d of docs) {
|
|
230
|
+
const author = (d.createdById && authorsMap.get(d.createdById)) ||
|
|
231
|
+
(d.updatedById && authorsMap.get(d.updatedById)) || {
|
|
232
|
+
id: '',
|
|
233
|
+
name: 'Unknown',
|
|
234
|
+
initials: '??',
|
|
235
|
+
}
|
|
236
|
+
allPosts.push({
|
|
237
|
+
id: d.id,
|
|
238
|
+
title: d.title ?? (d.data?.title as string) ?? '(untitled)',
|
|
239
|
+
slug: d.slug ?? (d.data?.slug as string) ?? '',
|
|
240
|
+
postTypeId: col.slug,
|
|
241
|
+
postTypeName: col.labels.singular,
|
|
242
|
+
postTypeAccentColor: col.color ?? null,
|
|
243
|
+
authorId: author.id,
|
|
244
|
+
authorName: author.name,
|
|
245
|
+
authorInitials: author.initials,
|
|
246
|
+
status: d.status,
|
|
247
|
+
publishDate: d.publishedAt ?? d.scheduledAt ?? null,
|
|
248
|
+
updatedDate: pickDisplayDate(d),
|
|
249
|
+
seoScore: extractSeoScore(d.data),
|
|
250
|
+
createdAt: d.createdAt,
|
|
251
|
+
updatedAt: d.updatedAt,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Compute per-type counts BEFORE filtering — the tabs at the top
|
|
257
|
+
// of the page reflect the dataset, not the active query.
|
|
258
|
+
const countsBySlug = new Map<string, number>()
|
|
259
|
+
for (const p of allPosts) {
|
|
260
|
+
countsBySlug.set(p.postTypeId, (countsBySlug.get(p.postTypeId) ?? 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
const types: PostType[] = cols.map((c) => collectionToPostType(c, countsBySlug.get(c.slug) ?? 0))
|
|
263
|
+
|
|
264
|
+
// Apply filters / search.
|
|
265
|
+
const search = (query.search ?? '').trim().toLowerCase()
|
|
266
|
+
const filtered = allPosts.filter((p) => {
|
|
267
|
+
if (query.postType && query.postType !== 'all' && p.postTypeId !== query.postType) return false
|
|
268
|
+
if (query.status && query.status !== 'all' && p.status !== query.status) return false
|
|
269
|
+
if (query.author && query.author !== 'all' && p.authorId !== query.author) return false
|
|
270
|
+
if (search) {
|
|
271
|
+
const haystack = `${p.title} ${p.slug} ${p.authorName} ${p.postTypeName}`.toLowerCase()
|
|
272
|
+
if (!haystack.includes(search)) return false
|
|
273
|
+
}
|
|
274
|
+
return true
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Sort.
|
|
278
|
+
const sortBy = query.sortBy ?? 'date'
|
|
279
|
+
const dir = query.sortDirection ?? 'desc'
|
|
280
|
+
filtered.sort((a, b) => {
|
|
281
|
+
const cmp = comparePostsFor(sortBy, a, b)
|
|
282
|
+
return dir === 'asc' ? cmp : -cmp
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// Paginate.
|
|
286
|
+
const page = Math.max(1, query.page ?? 1)
|
|
287
|
+
const pageSize = Math.max(1, query.pageSize ?? 10)
|
|
288
|
+
const start = (page - 1) * pageSize
|
|
289
|
+
const paged = filtered.slice(start, start + pageSize)
|
|
290
|
+
|
|
291
|
+
// Unique authors from the full set (so the dropdown lists every
|
|
292
|
+
// contributor, not just the currently visible page).
|
|
293
|
+
const authorIds = new Set<string>()
|
|
294
|
+
const authors: PostAuthor[] = []
|
|
295
|
+
for (const p of allPosts) {
|
|
296
|
+
if (p.authorId && !authorIds.has(p.authorId)) {
|
|
297
|
+
authorIds.add(p.authorId)
|
|
298
|
+
authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials })
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
authors.sort((a, b) => a.name.localeCompare(b.name))
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
posts: paged,
|
|
305
|
+
total: filtered.length,
|
|
306
|
+
page,
|
|
307
|
+
pageSize,
|
|
308
|
+
types,
|
|
309
|
+
authors,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function comparePostsFor(by: NonNullable<PostsQuery['sortBy']>, a: Post, b: Post): number {
|
|
314
|
+
switch (by) {
|
|
315
|
+
case 'title':
|
|
316
|
+
return a.title.localeCompare(b.title)
|
|
317
|
+
case 'type':
|
|
318
|
+
return a.postTypeName.localeCompare(b.postTypeName)
|
|
319
|
+
case 'author':
|
|
320
|
+
return a.authorName.localeCompare(b.authorName)
|
|
321
|
+
case 'status':
|
|
322
|
+
return a.status.localeCompare(b.status)
|
|
323
|
+
case 'seo': {
|
|
324
|
+
const av = a.seoScore ?? -1
|
|
325
|
+
const bv = b.seoScore ?? -1
|
|
326
|
+
return av - bv
|
|
327
|
+
}
|
|
328
|
+
case 'date':
|
|
329
|
+
default:
|
|
330
|
+
return new Date(a.updatedDate).getTime() - new Date(b.updatedDate).getTime()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Bulk mutations ──────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
export async function bulkUpdatePosts(
|
|
337
|
+
ids: Array<{ postTypeId: string; id: string }>,
|
|
338
|
+
payload: { status?: PostStatus },
|
|
339
|
+
): Promise<{ ok: number; failed: number }> {
|
|
340
|
+
let ok = 0
|
|
341
|
+
let failed = 0
|
|
342
|
+
for (const { postTypeId, id } of ids) {
|
|
343
|
+
const res = await cmsApi(`/collections/${encodeURIComponent(postTypeId)}/${id}`, {
|
|
344
|
+
method: 'PUT',
|
|
345
|
+
body: JSON.stringify(payload),
|
|
346
|
+
})
|
|
347
|
+
if (res.error) failed++
|
|
348
|
+
else ok++
|
|
349
|
+
}
|
|
350
|
+
return { ok, failed }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function bulkDeletePosts(
|
|
354
|
+
ids: Array<{ postTypeId: string; id: string }>,
|
|
355
|
+
): Promise<{ ok: number; failed: number }> {
|
|
356
|
+
let ok = 0
|
|
357
|
+
let failed = 0
|
|
358
|
+
for (const { postTypeId, id } of ids) {
|
|
359
|
+
const res = await cmsApi(`/collections/${encodeURIComponent(postTypeId)}/${id}`, {
|
|
360
|
+
method: 'DELETE',
|
|
361
|
+
})
|
|
362
|
+
if (res.error) failed++
|
|
363
|
+
else ok++
|
|
364
|
+
}
|
|
365
|
+
return { ok, failed }
|
|
366
|
+
}
|