@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,599 @@
1
+ /**
2
+ * Pages service — thin client over the existing REST endpoints, shaped
3
+ * around the data the Pages admin area needs.
4
+ *
5
+ * Data model (v1):
6
+ * - Pages are documents in the `pages` collection.
7
+ * · `data.path` — full URL path, e.g. "/about/story" (falls
8
+ * back to "/" + slug)
9
+ * · `data.tags` — string[] of tag slugs
10
+ * · `data.primaryTag` — tag slug used for "Group by tag"
11
+ * · `data.seoScore` / `data.metaTitle` / `data.metaDescription`
12
+ * - Tags are documents in the hidden `page-tags` collection.
13
+ * · title = tag name, slug = tag slug
14
+ * · `data.color` / `data.description` / `data.sortOrder` / `data.active`
15
+ *
16
+ * Why DB-backed tags (not code-defined like Post Types)?
17
+ * Tags are editorial data that teams create/rename/merge/delete at
18
+ * runtime. Post Types are content *models* (schema) and must be
19
+ * code-defined; tags are *labels* and belong in the database. Storing
20
+ * them as documents reuses the existing collection CRUD + access
21
+ * control with zero new endpoints.
22
+ *
23
+ * Filtering/sorting/grouping happen client-side over the fetched set
24
+ * (the seed is 13 rows). Promote to a server-side `/api/cms/pages`
25
+ * aggregate when the collection grows past ~5k rows or page-load
26
+ * latency exceeds 300ms — only this file changes; callers keep the same
27
+ * `Page[]` / `PageTag[]` shapes.
28
+ */
29
+ import { cmsApi } from './api.js'
30
+
31
+ export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
32
+
33
+ export const PAGES_COLLECTION = 'pages'
34
+ export const PAGE_TAGS_COLLECTION = 'page-tags'
35
+
36
+ export interface PageTag {
37
+ id: string // == slug (stable join key; pages store slugs in data.tags)
38
+ docId: string // underlying document id — required for tag CRUD
39
+ name: string
40
+ slug: string
41
+ description: string | null
42
+ color: string | null
43
+ pageCount: number
44
+ sortOrder: number
45
+ active: boolean
46
+ createdAt: string | null
47
+ updatedAt: string | null
48
+ }
49
+
50
+ export interface PageAuthor {
51
+ id: string
52
+ name: string
53
+ initials: string
54
+ }
55
+
56
+ export interface Page {
57
+ id: string
58
+ title: string
59
+ path: string
60
+ slug: string
61
+ parentPageId: string | null
62
+ tags: string[] // tag slugs
63
+ primaryTagId: string | null // tag slug
64
+ authorId: string
65
+ authorName: string
66
+ authorInitials: string
67
+ status: PageStatus
68
+ publishDate: string | null
69
+ scheduledDate: string | null
70
+ lastModified: string
71
+ seoScore: number | null
72
+ seoTitle: string | null
73
+ seoDescription: string | null
74
+ templateId: string | null
75
+ isHomepage: boolean
76
+ isSystemPage: boolean
77
+ createdAt: string
78
+ updatedAt: string
79
+ }
80
+
81
+ export type PageGroupBy = 'none' | 'tag' | 'status' | 'author'
82
+
83
+ export interface PagesQuery {
84
+ search?: string
85
+ tagIds?: string[] // tag slugs; OR semantics
86
+ status?: PageStatus | 'all'
87
+ authorId?: string | 'all'
88
+ groupBy?: PageGroupBy
89
+ sortBy?: 'title' | 'author' | 'status' | 'modified' | 'seo'
90
+ sortDirection?: 'asc' | 'desc'
91
+ page?: number
92
+ pageSize?: number
93
+ }
94
+
95
+ export interface PagesResult {
96
+ pages: Page[]
97
+ total: number
98
+ page: number
99
+ pageSize: number
100
+ tags: PageTag[]
101
+ authors: PageAuthor[]
102
+ }
103
+
104
+ // ─── API row shapes ──────────────────────────────────────────────────
105
+
106
+ interface DocumentApiRow {
107
+ id: string
108
+ collection: string
109
+ title: string | null
110
+ slug: string | null
111
+ status: PageStatus
112
+ createdById: string | null
113
+ updatedById: string | null
114
+ publishedAt: string | null
115
+ scheduledAt: string | null
116
+ createdAt: string
117
+ updatedAt: string
118
+ data: Record<string, unknown> | null
119
+ }
120
+
121
+ interface ListEnvelope {
122
+ docs: DocumentApiRow[]
123
+ total: number
124
+ }
125
+
126
+ interface UserApiRow {
127
+ id: string
128
+ name?: string | null
129
+ email: string
130
+ }
131
+
132
+ // ─── Helpers ─────────────────────────────────────────────────────────
133
+
134
+ function initialsFromName(name: string | null | undefined, email: string): string {
135
+ if (name && name.trim().length > 0) {
136
+ const parts = name.trim().split(/\s+/)
137
+ const first = parts[0]?.[0] ?? ''
138
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
139
+ return (first + last).toUpperCase() || (email[0] ?? '?').toUpperCase()
140
+ }
141
+ return (email[0] ?? '?').toUpperCase()
142
+ }
143
+
144
+ async function fetchAuthorsMap(): Promise<Map<string, PageAuthor>> {
145
+ const res = await cmsApi<{ users: UserApiRow[] } | UserApiRow[]>('/users?pageSize=200')
146
+ const list: UserApiRow[] = Array.isArray(res.data)
147
+ ? res.data
148
+ : ((res.data as { users?: UserApiRow[] } | null)?.users ?? [])
149
+ const map = new Map<string, PageAuthor>()
150
+ for (const u of list) {
151
+ map.set(u.id, {
152
+ id: u.id,
153
+ name: u.name?.trim() || u.email,
154
+ initials: initialsFromName(u.name, u.email),
155
+ })
156
+ }
157
+ return map
158
+ }
159
+
160
+ function asString(v: unknown): string | null {
161
+ return typeof v === 'string' && v.length > 0 ? v : null
162
+ }
163
+
164
+ function asStringArray(v: unknown): string[] {
165
+ if (!Array.isArray(v)) return []
166
+ return v.filter((x): x is string => typeof x === 'string' && x.length > 0)
167
+ }
168
+
169
+ function extractSeoScore(data: Record<string, unknown> | null): number | null {
170
+ if (!data) return null
171
+ for (const k of ['seoScore', 'seo_score', 'metaScore']) {
172
+ const val = data[k]
173
+ if (typeof val === 'number' && Number.isFinite(val)) return val
174
+ }
175
+ return null
176
+ }
177
+
178
+ function pageDisplayDate(d: DocumentApiRow): string {
179
+ if (d.status === 'SCHEDULED' && d.scheduledAt) return d.scheduledAt
180
+ if (d.status === 'PUBLISHED' && d.publishedAt) return d.publishedAt
181
+ return d.updatedAt
182
+ }
183
+
184
+ // ─── Tags ────────────────────────────────────────────────────────────
185
+
186
+ function docToTag(d: DocumentApiRow, pageCount: number): PageTag {
187
+ const data = d.data ?? {}
188
+ return {
189
+ id: d.slug ?? d.id,
190
+ docId: d.id,
191
+ name: d.title ?? d.slug ?? '(untitled tag)',
192
+ slug: d.slug ?? d.id,
193
+ description: asString(data.description),
194
+ color: asString(data.color),
195
+ pageCount,
196
+ sortOrder: typeof data.sortOrder === 'number' ? data.sortOrder : 0,
197
+ active: data.active !== false,
198
+ createdAt: d.createdAt ?? null,
199
+ updatedAt: d.updatedAt ?? null,
200
+ }
201
+ }
202
+
203
+ async function fetchTagDocs(): Promise<DocumentApiRow[]> {
204
+ const res = await cmsApi<ListEnvelope>(
205
+ `/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`,
206
+ )
207
+ return (res.data?.docs ?? []) as DocumentApiRow[]
208
+ }
209
+
210
+ /**
211
+ * Fetch all tags. `pageCounts` (slug → count) is optional so the tag
212
+ * manager / pickers can render before page data is loaded.
213
+ */
214
+ export async function fetchPageTags(pageCounts?: Map<string, number>): Promise<PageTag[]> {
215
+ const docs = await fetchTagDocs()
216
+ const tags = docs.map((d) => docToTag(d, pageCounts?.get(d.slug ?? d.id) ?? 0))
217
+ tags.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
218
+ return tags
219
+ }
220
+
221
+ export async function fetchAuthors(): Promise<PageAuthor[]> {
222
+ const map = await fetchAuthorsMap()
223
+ return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
224
+ }
225
+
226
+ // ─── Pages ───────────────────────────────────────────────────────────
227
+
228
+ function docToPage(d: DocumentApiRow, authors: Map<string, PageAuthor>): Page {
229
+ const data = d.data ?? {}
230
+ const slug = d.slug ?? asString(data.slug) ?? ''
231
+ const path = asString(data.path) ?? (slug ? `/${slug}`.replace(/\/+/g, '/') : '/')
232
+ const author = (d.createdById && authors.get(d.createdById)) ||
233
+ (d.updatedById && authors.get(d.updatedById)) || { id: '', name: 'Unknown', initials: '??' }
234
+ const tags = asStringArray(data.tags)
235
+ const primaryTagId = asString(data.primaryTag) ?? tags[0] ?? null
236
+
237
+ return {
238
+ id: d.id,
239
+ title: d.title ?? asString(data.title) ?? '(untitled)',
240
+ path,
241
+ slug,
242
+ parentPageId: asString(data.parentPageId),
243
+ tags,
244
+ primaryTagId,
245
+ authorId: author.id,
246
+ authorName: author.name,
247
+ authorInitials: author.initials,
248
+ status: d.status,
249
+ publishDate: d.publishedAt ?? null,
250
+ scheduledDate: d.scheduledAt ?? null,
251
+ lastModified: pageDisplayDate(d),
252
+ seoScore: extractSeoScore(data),
253
+ seoTitle: asString(data.metaTitle) ?? asString(data.seoTitle),
254
+ seoDescription: asString(data.metaDescription) ?? asString(data.seoDescription),
255
+ templateId: asString(data.templateId),
256
+ isHomepage: path === '/' || data.isHomepage === true,
257
+ isSystemPage: data.isSystemPage === true,
258
+ createdAt: d.createdAt,
259
+ updatedAt: d.updatedAt,
260
+ }
261
+ }
262
+
263
+ function comparePagesFor(by: NonNullable<PagesQuery['sortBy']>, a: Page, b: Page): number {
264
+ switch (by) {
265
+ case 'author':
266
+ return a.authorName.localeCompare(b.authorName)
267
+ case 'status':
268
+ return a.status.localeCompare(b.status)
269
+ case 'seo': {
270
+ const av = a.seoScore ?? -1
271
+ const bv = b.seoScore ?? -1
272
+ return av - bv
273
+ }
274
+ case 'modified':
275
+ return new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
276
+ case 'title':
277
+ default:
278
+ return a.title.localeCompare(b.title)
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Pure filter+sort over an in-memory page set. Exported for unit tests
284
+ * (keeps filtering logic decoupled from the network).
285
+ */
286
+ export function applyPagesQuery(all: Page[], query: PagesQuery): Page[] {
287
+ const search = (query.search ?? '').trim().toLowerCase()
288
+ const tagFilter = query.tagIds && query.tagIds.length > 0 ? new Set(query.tagIds) : null
289
+
290
+ const filtered = all.filter((p) => {
291
+ if (query.status && query.status !== 'all' && p.status !== query.status) return false
292
+ if (query.authorId && query.authorId !== 'all' && p.authorId !== query.authorId) return false
293
+ // Tag chips use OR semantics: keep a page if it has any selected tag.
294
+ if (tagFilter && !p.tags.some((t) => tagFilter.has(t))) return false
295
+ if (search) {
296
+ const haystack = [
297
+ p.title,
298
+ p.path,
299
+ p.slug,
300
+ p.authorName,
301
+ p.status,
302
+ p.seoTitle ?? '',
303
+ p.seoDescription ?? '',
304
+ p.tags.join(' '),
305
+ ]
306
+ .join(' ')
307
+ .toLowerCase()
308
+ if (!haystack.includes(search)) return false
309
+ }
310
+ return true
311
+ })
312
+
313
+ const sortBy = query.sortBy ?? 'title'
314
+ const dir = query.sortDirection ?? 'asc'
315
+ filtered.sort((a, b) => {
316
+ const cmp = comparePagesFor(sortBy, a, b)
317
+ return dir === 'asc' ? cmp : -cmp
318
+ })
319
+ return filtered
320
+ }
321
+
322
+ export interface PageGroup {
323
+ key: string
324
+ label: string
325
+ color: string | null
326
+ pages: Page[]
327
+ }
328
+
329
+ /**
330
+ * Group an already-filtered/sorted page list. A page appears in exactly
331
+ * ONE group (no duplicates): for `tag`, its primary tag (or first tag,
332
+ * or "Untagged"). Returns a single "All pages" group for `none`.
333
+ */
334
+ export function groupPages(pages: Page[], groupBy: PageGroupBy, tags: PageTag[]): PageGroup[] {
335
+ if (groupBy === 'none') {
336
+ return [{ key: 'all', label: 'All pages', color: null, pages }]
337
+ }
338
+
339
+ const tagBySlug = new Map(tags.map((t) => [t.slug, t]))
340
+ const buckets = new Map<string, PageGroup>()
341
+ const order: string[] = []
342
+
343
+ const ensure = (key: string, label: string, color: string | null) => {
344
+ if (!buckets.has(key)) {
345
+ buckets.set(key, { key, label, color, pages: [] })
346
+ order.push(key)
347
+ }
348
+ return buckets.get(key)!
349
+ }
350
+
351
+ for (const p of pages) {
352
+ if (groupBy === 'tag') {
353
+ const slug = p.primaryTagId ?? p.tags[0] ?? null
354
+ if (!slug) {
355
+ ensure('__untagged', 'Untagged', null).pages.push(p)
356
+ } else {
357
+ const tag = tagBySlug.get(slug)
358
+ ensure(slug, tag?.name ?? slug, tag?.color ?? null).pages.push(p)
359
+ }
360
+ } else if (groupBy === 'status') {
361
+ ensure(p.status, statusLabel(p.status), null).pages.push(p)
362
+ } else if (groupBy === 'author') {
363
+ ensure(p.authorId || '__unknown', p.authorName, null).pages.push(p)
364
+ }
365
+ }
366
+
367
+ return order.map((k) => buckets.get(k)!)
368
+ }
369
+
370
+ function statusLabel(s: PageStatus): string {
371
+ return s.charAt(0) + s.slice(1).toLowerCase()
372
+ }
373
+
374
+ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
375
+ const authorsMap = await fetchAuthorsMap()
376
+
377
+ const [pagesRes, tagDocs] = await Promise.all([
378
+ cmsApi<ListEnvelope>(`/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`),
379
+ fetchTagDocs(),
380
+ ])
381
+
382
+ const allPages = ((pagesRes.data?.docs ?? []) as DocumentApiRow[]).map((d) =>
383
+ docToPage(d, authorsMap),
384
+ )
385
+
386
+ // Tag page counts come from the full (unfiltered) set so chips and the
387
+ // tag manager reflect the dataset, not the active query.
388
+ const counts = new Map<string, number>()
389
+ for (const p of allPages) {
390
+ for (const slug of p.tags) counts.set(slug, (counts.get(slug) ?? 0) + 1)
391
+ }
392
+ const tags = tagDocs
393
+ .map((d) => docToTag(d, counts.get(d.slug ?? d.id) ?? 0))
394
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
395
+
396
+ const filtered = applyPagesQuery(allPages, query)
397
+
398
+ // Pagination only applies to the flat (ungrouped) view; grouped views
399
+ // render every match so the buckets stay complete.
400
+ const grouped = query.groupBy && query.groupBy !== 'none'
401
+ const page = Math.max(1, query.page ?? 1)
402
+ const pageSize = Math.max(1, query.pageSize ?? 25)
403
+ const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
404
+
405
+ // Authors present in the dataset (for the Author filter dropdown).
406
+ const seen = new Set<string>()
407
+ const authors: PageAuthor[] = []
408
+ for (const p of allPages) {
409
+ if (p.authorId && !seen.has(p.authorId)) {
410
+ seen.add(p.authorId)
411
+ authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials })
412
+ }
413
+ }
414
+ authors.sort((a, b) => a.name.localeCompare(b.name))
415
+
416
+ return { pages: paged, total: filtered.length, page, pageSize, tags, authors }
417
+ }
418
+
419
+ export async function fetchPageById(pageId: string): Promise<Page | null> {
420
+ const res = await cmsApi<DocumentApiRow>(
421
+ `/collections/${PAGES_COLLECTION}/${encodeURIComponent(pageId)}`,
422
+ )
423
+ if (res.error || !res.data) return null
424
+ const authors = await fetchAuthorsMap()
425
+ return docToPage(res.data, authors)
426
+ }
427
+
428
+ // ─── Bulk page mutations ─────────────────────────────────────────────
429
+
430
+ export interface BulkResult {
431
+ ok: number
432
+ failed: number
433
+ }
434
+
435
+ export async function bulkUpdatePages(
436
+ ids: string[],
437
+ payload: { status?: PageStatus; data?: Record<string, unknown> },
438
+ ): Promise<BulkResult> {
439
+ let ok = 0
440
+ let failed = 0
441
+ for (const id of ids) {
442
+ const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
443
+ method: 'PUT',
444
+ body: JSON.stringify(payload),
445
+ })
446
+ if (res.error) failed++
447
+ else ok++
448
+ }
449
+ return { ok, failed }
450
+ }
451
+
452
+ export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
453
+ let ok = 0
454
+ let failed = 0
455
+ for (const id of ids) {
456
+ const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
457
+ method: 'DELETE',
458
+ })
459
+ if (res.error) failed++
460
+ else ok++
461
+ }
462
+ return { ok, failed }
463
+ }
464
+
465
+ /**
466
+ * Add or remove tag slugs across many pages. We fetch each page's
467
+ * current tags, merge/strip, then PUT the new `data.tags`. Done
468
+ * per-page because tags live in the document JSON (no bulk-tag endpoint
469
+ * yet). `primaryTag` is cleared if it was removed.
470
+ */
471
+ export async function bulkChangePageTags(
472
+ pages: Pick<Page, 'id' | 'tags' | 'primaryTagId'>[],
473
+ change: { add?: string[]; remove?: string[] },
474
+ ): Promise<BulkResult> {
475
+ let ok = 0
476
+ let failed = 0
477
+ const add = new Set(change.add ?? [])
478
+ const remove = new Set(change.remove ?? [])
479
+ for (const p of pages) {
480
+ const next = new Set(p.tags)
481
+ for (const t of add) next.add(t)
482
+ for (const t of remove) next.delete(t)
483
+ const tags = [...next]
484
+ const primaryTag =
485
+ p.primaryTagId && next.has(p.primaryTagId) ? p.primaryTagId : (tags[0] ?? null)
486
+ const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
487
+ method: 'PUT',
488
+ body: JSON.stringify({ data: { tags, primaryTag } }),
489
+ })
490
+ if (res.error) failed++
491
+ else ok++
492
+ }
493
+ return { ok, failed }
494
+ }
495
+
496
+ // ─── Tag CRUD ────────────────────────────────────────────────────────
497
+
498
+ export interface PageTagInput {
499
+ name: string
500
+ slug?: string
501
+ color?: string
502
+ description?: string
503
+ sortOrder?: number
504
+ active?: boolean
505
+ }
506
+
507
+ function slugify(input: string): string {
508
+ return input
509
+ .toLowerCase()
510
+ .trim()
511
+ .replace(/[^a-z0-9]+/g, '-')
512
+ .replace(/^-+|-+$/g, '')
513
+ }
514
+
515
+ export async function createPageTag(input: PageTagInput): Promise<{ error?: string }> {
516
+ const slug = input.slug?.trim() || slugify(input.name)
517
+ if (!slug) return { error: 'Tag name is required' }
518
+ const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}`, {
519
+ method: 'POST',
520
+ body: JSON.stringify({
521
+ title: input.name,
522
+ slug,
523
+ status: 'PUBLISHED',
524
+ data: {
525
+ color: input.color ?? 'gray',
526
+ description: input.description ?? '',
527
+ sortOrder: input.sortOrder ?? 0,
528
+ active: input.active !== false,
529
+ },
530
+ }),
531
+ })
532
+ return res.error ? { error: res.error } : {}
533
+ }
534
+
535
+ export async function updatePageTag(
536
+ tagDocId: string,
537
+ input: Partial<PageTagInput>,
538
+ ): Promise<{ error?: string }> {
539
+ const data: Record<string, unknown> = {}
540
+ if (input.color !== undefined) data.color = input.color
541
+ if (input.description !== undefined) data.description = input.description
542
+ if (input.sortOrder !== undefined) data.sortOrder = input.sortOrder
543
+ if (input.active !== undefined) data.active = input.active
544
+ const body: Record<string, unknown> = { data }
545
+ if (input.name !== undefined) body.title = input.name
546
+ const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
547
+ method: 'PUT',
548
+ body: JSON.stringify(body),
549
+ })
550
+ return res.error ? { error: res.error } : {}
551
+ }
552
+
553
+ export async function deletePageTag(tagDocId: string): Promise<{ error?: string }> {
554
+ const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
555
+ method: 'DELETE',
556
+ })
557
+ return res.error ? { error: res.error } : {}
558
+ }
559
+
560
+ /**
561
+ * Delete a tag and detach it from every page that uses it (so no
562
+ * dangling slugs remain in page `data.tags`). Used by the tag manager's
563
+ * "remove from all pages" deletion path.
564
+ */
565
+ export async function deletePageTagWithDetach(tag: PageTag): Promise<{ error?: string }> {
566
+ if (tag.pageCount > 0) {
567
+ const res = await fetchPages({ tagIds: [tag.slug], pageSize: 1000 })
568
+ if (res.pages.length > 0) {
569
+ await bulkChangePageTags(
570
+ res.pages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
571
+ { remove: [tag.slug] },
572
+ )
573
+ }
574
+ }
575
+ return deletePageTag(tag.docId)
576
+ }
577
+
578
+ /**
579
+ * Merge `source` into `target`: every page tagged with the source slug
580
+ * gets the target slug (primary tag re-pointed too), then the source
581
+ * tag document is deleted.
582
+ */
583
+ export async function mergePageTags(source: PageTag, target: PageTag): Promise<{ error?: string }> {
584
+ if (source.slug === target.slug) return { error: 'Cannot merge a tag into itself' }
585
+ const res = await fetchPages({ tagIds: [source.slug], pageSize: 1000 })
586
+ for (const p of res.pages) {
587
+ const next = new Set(p.tags)
588
+ next.delete(source.slug)
589
+ next.add(target.slug)
590
+ const tags = [...next]
591
+ const primaryTag = p.primaryTagId === source.slug ? target.slug : p.primaryTagId
592
+ const put = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
593
+ method: 'PUT',
594
+ body: JSON.stringify({ data: { tags, primaryTag } }),
595
+ })
596
+ if (put.error) return { error: put.error }
597
+ }
598
+ return deletePageTag(source.docId)
599
+ }