@actuate-media/cms-admin 0.14.1 → 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.
Files changed (304) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +148 -2
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
  5. package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/posts-badges.test.js +68 -0
  7. package/dist/__tests__/components/posts-badges.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
  11. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
  12. package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
  13. package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/page-editor-service.test.js +137 -0
  15. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
  16. package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
  17. package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
  18. package/dist/__tests__/lib/pages-service.test.js +140 -0
  19. package/dist/__tests__/lib/pages-service.test.js.map +1 -0
  20. package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
  21. package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
  22. package/dist/__tests__/lib/post-editor-service.test.js +185 -0
  23. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
  24. package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
  25. package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
  26. package/dist/__tests__/lib/posts-service.test.js +244 -0
  27. package/dist/__tests__/lib/posts-service.test.js.map +1 -0
  28. package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
  29. package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/posts-list-view.test.js +98 -0
  31. package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
  32. package/dist/actuate-admin.css +1 -1
  33. package/dist/components/pages/tag-badges.d.ts +21 -0
  34. package/dist/components/pages/tag-badges.d.ts.map +1 -0
  35. package/dist/components/pages/tag-badges.js +56 -0
  36. package/dist/components/pages/tag-badges.js.map +1 -0
  37. package/dist/components/posts/badges.d.ts +25 -0
  38. package/dist/components/posts/badges.d.ts.map +1 -0
  39. package/dist/components/posts/badges.js +123 -0
  40. package/dist/components/posts/badges.js.map +1 -0
  41. package/dist/components/posts/icons.d.ts +12 -0
  42. package/dist/components/posts/icons.d.ts.map +1 -0
  43. package/dist/components/posts/icons.js +26 -0
  44. package/dist/components/posts/icons.js.map +1 -0
  45. package/dist/layout/Header.d.ts +14 -1
  46. package/dist/layout/Header.d.ts.map +1 -1
  47. package/dist/layout/Header.js +10 -3
  48. package/dist/layout/Header.js.map +1 -1
  49. package/dist/layout/Layout.d.ts.map +1 -1
  50. package/dist/layout/Layout.js +78 -2
  51. package/dist/layout/Layout.js.map +1 -1
  52. package/dist/layout/Sidebar.d.ts +16 -0
  53. package/dist/layout/Sidebar.d.ts.map +1 -1
  54. package/dist/layout/Sidebar.js +176 -49
  55. package/dist/layout/Sidebar.js.map +1 -1
  56. package/dist/lib/page-editor-service.d.ts +45 -0
  57. package/dist/lib/page-editor-service.d.ts.map +1 -0
  58. package/dist/lib/page-editor-service.js +153 -0
  59. package/dist/lib/page-editor-service.js.map +1 -0
  60. package/dist/lib/pages-service.d.ts +143 -0
  61. package/dist/lib/pages-service.d.ts.map +1 -0
  62. package/dist/lib/pages-service.js +444 -0
  63. package/dist/lib/pages-service.js.map +1 -0
  64. package/dist/lib/post-editor-service.d.ts +57 -0
  65. package/dist/lib/post-editor-service.d.ts.map +1 -0
  66. package/dist/lib/post-editor-service.js +234 -0
  67. package/dist/lib/post-editor-service.js.map +1 -0
  68. package/dist/lib/posts-service.d.ts +73 -0
  69. package/dist/lib/posts-service.d.ts.map +1 -0
  70. package/dist/lib/posts-service.js +245 -0
  71. package/dist/lib/posts-service.js.map +1 -0
  72. package/dist/lib/preview-link.d.ts +32 -0
  73. package/dist/lib/preview-link.d.ts.map +1 -0
  74. package/dist/lib/preview-link.js +40 -0
  75. package/dist/lib/preview-link.js.map +1 -0
  76. package/dist/router/index.d.ts +1 -0
  77. package/dist/router/index.d.ts.map +1 -1
  78. package/dist/router/index.js +9 -1
  79. package/dist/router/index.js.map +1 -1
  80. package/dist/views/Dashboard.d.ts.map +1 -1
  81. package/dist/views/Dashboard.js +147 -62
  82. package/dist/views/Dashboard.js.map +1 -1
  83. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  84. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  85. package/dist/views/Pages/PageTagManager.js +145 -0
  86. package/dist/views/Pages/PageTagManager.js.map +1 -0
  87. package/dist/views/Pages/PagesListView.d.ts +18 -0
  88. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  89. package/dist/views/Pages/PagesListView.js +287 -0
  90. package/dist/views/Pages/PagesListView.js.map +1 -0
  91. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  92. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  93. package/dist/views/Posts/NewPostPicker.js +59 -0
  94. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  95. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  96. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  97. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  98. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  99. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  100. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  101. package/dist/views/Posts/PostTypesView.js +52 -0
  102. package/dist/views/Posts/PostTypesView.js.map +1 -0
  103. package/dist/views/Posts/PostsListView.d.ts +25 -0
  104. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  105. package/dist/views/Posts/PostsListView.js +239 -0
  106. package/dist/views/Posts/PostsListView.js.map +1 -0
  107. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  108. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  109. package/dist/views/Posts/post-type-presets.js +72 -0
  110. package/dist/views/Posts/post-type-presets.js.map +1 -0
  111. package/dist/views/page-builder/Widgets.d.ts +13 -0
  112. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  113. package/dist/views/page-builder/Widgets.js +15 -0
  114. package/dist/views/page-builder/Widgets.js.map +1 -0
  115. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  116. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  117. package/dist/views/page-editor/AddSectionModal.js +22 -0
  118. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  119. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  120. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  121. package/dist/views/page-editor/EditorCanvas.js +64 -0
  122. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  123. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  124. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  125. package/dist/views/page-editor/EditorTopBar.js +42 -0
  126. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  127. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  128. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  129. package/dist/views/page-editor/PagePreview.js +37 -0
  130. package/dist/views/page-editor/PagePreview.js.map +1 -0
  131. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  132. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  133. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  134. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  135. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  136. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  137. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  138. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  139. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  140. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  141. package/dist/views/page-editor/SectionInspector.js +98 -0
  142. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  143. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  144. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  145. package/dist/views/page-editor/SectionsPanel.js +43 -0
  146. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  147. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  148. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  149. package/dist/views/page-editor/ValidationSummary.js +10 -0
  150. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  151. package/dist/views/page-editor/section-icons.d.ts +4 -0
  152. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  153. package/dist/views/page-editor/section-icons.js +20 -0
  154. package/dist/views/page-editor/section-icons.js.map +1 -0
  155. package/dist/views/page-editor/section-types.d.ts +11 -0
  156. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  157. package/dist/views/page-editor/section-types.js +10 -0
  158. package/dist/views/page-editor/section-types.js.map +1 -0
  159. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  160. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  161. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  162. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  163. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  164. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  165. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  166. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  167. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  168. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  169. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  170. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  171. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  172. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  173. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  174. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  175. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  176. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  177. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  178. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  179. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  180. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  181. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  182. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  183. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  184. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  185. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  186. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  187. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  188. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  189. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  190. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  191. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  192. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  193. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  194. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  195. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  196. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  197. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  198. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  199. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  200. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  201. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  202. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  203. package/dist/views/page-editor/sections/index.d.ts +14 -0
  204. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  205. package/dist/views/page-editor/sections/index.js +12 -0
  206. package/dist/views/page-editor/sections/index.js.map +1 -0
  207. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  208. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  209. package/dist/views/page-editor/sections/parts.js +60 -0
  210. package/dist/views/page-editor/sections/parts.js.map +1 -0
  211. package/dist/views/page-editor/viewports.d.ts +14 -0
  212. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  213. package/dist/views/page-editor/viewports.js +12 -0
  214. package/dist/views/page-editor/viewports.js.map +1 -0
  215. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  216. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  217. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  218. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  219. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  220. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  221. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  222. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  223. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  224. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  225. package/dist/views/post-editor/PostHeader.js +27 -0
  226. package/dist/views/post-editor/PostHeader.js.map +1 -0
  227. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  228. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  229. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  230. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  231. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  232. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  233. package/dist/views/post-editor/PostPreview.js +53 -0
  234. package/dist/views/post-editor/PostPreview.js.map +1 -0
  235. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  236. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  237. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  238. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  239. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  240. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  241. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  242. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  243. package/package.json +2 -2
  244. package/src/AdminRoot.tsx +213 -2
  245. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  246. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  247. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  248. package/src/__tests__/lib/pages-service.test.ts +157 -0
  249. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  250. package/src/__tests__/lib/posts-service.test.ts +279 -0
  251. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  252. package/src/components/pages/tag-badges.tsx +104 -0
  253. package/src/components/posts/badges.tsx +170 -0
  254. package/src/components/posts/icons.ts +40 -0
  255. package/src/layout/Header.tsx +90 -43
  256. package/src/layout/Layout.tsx +166 -1
  257. package/src/layout/Sidebar.tsx +287 -101
  258. package/src/lib/page-editor-service.ts +233 -0
  259. package/src/lib/pages-service.ts +599 -0
  260. package/src/lib/post-editor-service.ts +342 -0
  261. package/src/lib/posts-service.ts +366 -0
  262. package/src/lib/preview-link.ts +63 -0
  263. package/src/router/index.ts +10 -1
  264. package/src/views/Dashboard.tsx +270 -131
  265. package/src/views/Pages/PageTagManager.tsx +436 -0
  266. package/src/views/Pages/PagesListView.tsx +944 -0
  267. package/src/views/Posts/NewPostPicker.tsx +147 -0
  268. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  269. package/src/views/Posts/PostTypesView.tsx +194 -0
  270. package/src/views/Posts/PostsListView.tsx +869 -0
  271. package/src/views/Posts/post-type-presets.ts +105 -0
  272. package/src/views/page-builder/Widgets.tsx +44 -0
  273. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  274. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  275. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  276. package/src/views/page-editor/PagePreview.tsx +96 -0
  277. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  278. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  279. package/src/views/page-editor/SectionInspector.tsx +599 -0
  280. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  281. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  282. package/src/views/page-editor/section-icons.ts +33 -0
  283. package/src/views/page-editor/section-types.ts +27 -0
  284. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  285. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  286. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  287. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  288. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  289. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  290. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  291. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  292. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  293. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  294. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  295. package/src/views/page-editor/sections/index.ts +13 -0
  296. package/src/views/page-editor/sections/parts.tsx +113 -0
  297. package/src/views/page-editor/viewports.ts +25 -0
  298. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  299. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  300. package/src/views/post-editor/PostHeader.tsx +87 -0
  301. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  302. package/src/views/post-editor/PostPreview.tsx +114 -0
  303. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  304. 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
+ }