@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.
Files changed (298) 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/Layout.js +36 -8
  46. package/dist/layout/Layout.js.map +1 -1
  47. package/dist/layout/Sidebar.d.ts +9 -0
  48. package/dist/layout/Sidebar.d.ts.map +1 -1
  49. package/dist/layout/Sidebar.js +100 -48
  50. package/dist/layout/Sidebar.js.map +1 -1
  51. package/dist/lib/page-editor-service.d.ts +45 -0
  52. package/dist/lib/page-editor-service.d.ts.map +1 -0
  53. package/dist/lib/page-editor-service.js +153 -0
  54. package/dist/lib/page-editor-service.js.map +1 -0
  55. package/dist/lib/pages-service.d.ts +143 -0
  56. package/dist/lib/pages-service.d.ts.map +1 -0
  57. package/dist/lib/pages-service.js +444 -0
  58. package/dist/lib/pages-service.js.map +1 -0
  59. package/dist/lib/post-editor-service.d.ts +57 -0
  60. package/dist/lib/post-editor-service.d.ts.map +1 -0
  61. package/dist/lib/post-editor-service.js +234 -0
  62. package/dist/lib/post-editor-service.js.map +1 -0
  63. package/dist/lib/posts-service.d.ts +73 -0
  64. package/dist/lib/posts-service.d.ts.map +1 -0
  65. package/dist/lib/posts-service.js +245 -0
  66. package/dist/lib/posts-service.js.map +1 -0
  67. package/dist/lib/preview-link.d.ts +32 -0
  68. package/dist/lib/preview-link.d.ts.map +1 -0
  69. package/dist/lib/preview-link.js +40 -0
  70. package/dist/lib/preview-link.js.map +1 -0
  71. package/dist/router/index.d.ts +1 -0
  72. package/dist/router/index.d.ts.map +1 -1
  73. package/dist/router/index.js +9 -1
  74. package/dist/router/index.js.map +1 -1
  75. package/dist/views/Dashboard.d.ts.map +1 -1
  76. package/dist/views/Dashboard.js +14 -3
  77. package/dist/views/Dashboard.js.map +1 -1
  78. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  79. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  80. package/dist/views/Pages/PageTagManager.js +145 -0
  81. package/dist/views/Pages/PageTagManager.js.map +1 -0
  82. package/dist/views/Pages/PagesListView.d.ts +18 -0
  83. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  84. package/dist/views/Pages/PagesListView.js +287 -0
  85. package/dist/views/Pages/PagesListView.js.map +1 -0
  86. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  87. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  88. package/dist/views/Posts/NewPostPicker.js +59 -0
  89. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  90. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  91. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  92. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  93. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  94. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  95. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  96. package/dist/views/Posts/PostTypesView.js +52 -0
  97. package/dist/views/Posts/PostTypesView.js.map +1 -0
  98. package/dist/views/Posts/PostsListView.d.ts +25 -0
  99. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  100. package/dist/views/Posts/PostsListView.js +239 -0
  101. package/dist/views/Posts/PostsListView.js.map +1 -0
  102. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  103. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  104. package/dist/views/Posts/post-type-presets.js +72 -0
  105. package/dist/views/Posts/post-type-presets.js.map +1 -0
  106. package/dist/views/page-builder/Widgets.d.ts +13 -0
  107. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  108. package/dist/views/page-builder/Widgets.js +15 -0
  109. package/dist/views/page-builder/Widgets.js.map +1 -0
  110. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  111. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  112. package/dist/views/page-editor/AddSectionModal.js +22 -0
  113. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  114. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  115. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  116. package/dist/views/page-editor/EditorCanvas.js +64 -0
  117. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  118. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  119. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  120. package/dist/views/page-editor/EditorTopBar.js +42 -0
  121. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  122. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  123. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  124. package/dist/views/page-editor/PagePreview.js +37 -0
  125. package/dist/views/page-editor/PagePreview.js.map +1 -0
  126. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  127. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  128. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  129. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  130. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  131. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  132. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  133. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  134. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  135. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  136. package/dist/views/page-editor/SectionInspector.js +98 -0
  137. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  138. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  139. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  140. package/dist/views/page-editor/SectionsPanel.js +43 -0
  141. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  142. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  143. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  144. package/dist/views/page-editor/ValidationSummary.js +10 -0
  145. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  146. package/dist/views/page-editor/section-icons.d.ts +4 -0
  147. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  148. package/dist/views/page-editor/section-icons.js +20 -0
  149. package/dist/views/page-editor/section-icons.js.map +1 -0
  150. package/dist/views/page-editor/section-types.d.ts +11 -0
  151. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  152. package/dist/views/page-editor/section-types.js +10 -0
  153. package/dist/views/page-editor/section-types.js.map +1 -0
  154. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  155. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  156. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  157. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  158. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  159. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  160. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  161. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  162. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  163. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  164. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  165. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  166. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  167. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  168. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  169. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  170. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  171. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  172. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  173. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  174. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  175. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  176. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  177. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  178. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  179. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  180. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  181. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  182. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  183. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  184. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  185. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  186. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  187. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  188. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  189. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  190. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  191. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  192. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  193. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  194. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  195. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  196. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  197. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  198. package/dist/views/page-editor/sections/index.d.ts +14 -0
  199. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  200. package/dist/views/page-editor/sections/index.js +12 -0
  201. package/dist/views/page-editor/sections/index.js.map +1 -0
  202. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  203. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  204. package/dist/views/page-editor/sections/parts.js +60 -0
  205. package/dist/views/page-editor/sections/parts.js.map +1 -0
  206. package/dist/views/page-editor/viewports.d.ts +14 -0
  207. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  208. package/dist/views/page-editor/viewports.js +12 -0
  209. package/dist/views/page-editor/viewports.js.map +1 -0
  210. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  211. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  212. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  213. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  214. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  215. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  216. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  217. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  218. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  219. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  220. package/dist/views/post-editor/PostHeader.js +27 -0
  221. package/dist/views/post-editor/PostHeader.js.map +1 -0
  222. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  223. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  224. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  225. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  226. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  227. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  228. package/dist/views/post-editor/PostPreview.js +53 -0
  229. package/dist/views/post-editor/PostPreview.js.map +1 -0
  230. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  231. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  232. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  233. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  234. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  235. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  236. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  237. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  238. package/package.json +2 -2
  239. package/src/AdminRoot.tsx +213 -2
  240. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  241. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  242. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  243. package/src/__tests__/lib/pages-service.test.ts +157 -0
  244. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  245. package/src/__tests__/lib/posts-service.test.ts +279 -0
  246. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  247. package/src/components/pages/tag-badges.tsx +104 -0
  248. package/src/components/posts/badges.tsx +170 -0
  249. package/src/components/posts/icons.ts +40 -0
  250. package/src/layout/Layout.tsx +66 -10
  251. package/src/layout/Sidebar.tsx +116 -50
  252. package/src/lib/page-editor-service.ts +233 -0
  253. package/src/lib/pages-service.ts +599 -0
  254. package/src/lib/post-editor-service.ts +342 -0
  255. package/src/lib/posts-service.ts +366 -0
  256. package/src/lib/preview-link.ts +63 -0
  257. package/src/router/index.ts +10 -1
  258. package/src/views/Dashboard.tsx +15 -3
  259. package/src/views/Pages/PageTagManager.tsx +436 -0
  260. package/src/views/Pages/PagesListView.tsx +944 -0
  261. package/src/views/Posts/NewPostPicker.tsx +147 -0
  262. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  263. package/src/views/Posts/PostTypesView.tsx +194 -0
  264. package/src/views/Posts/PostsListView.tsx +869 -0
  265. package/src/views/Posts/post-type-presets.ts +105 -0
  266. package/src/views/page-builder/Widgets.tsx +44 -0
  267. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  268. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  269. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  270. package/src/views/page-editor/PagePreview.tsx +96 -0
  271. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  272. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  273. package/src/views/page-editor/SectionInspector.tsx +599 -0
  274. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  275. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  276. package/src/views/page-editor/section-icons.ts +33 -0
  277. package/src/views/page-editor/section-types.ts +27 -0
  278. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  279. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  280. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  281. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  282. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  283. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  284. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  285. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  286. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  287. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  288. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  289. package/src/views/page-editor/sections/index.ts +13 -0
  290. package/src/views/page-editor/sections/parts.tsx +113 -0
  291. package/src/views/page-editor/viewports.ts +25 -0
  292. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  293. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  294. package/src/views/post-editor/PostHeader.tsx +87 -0
  295. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  296. package/src/views/post-editor/PostPreview.tsx +114 -0
  297. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  298. package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
@@ -0,0 +1,279 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // `cmsApi` is the only dependency the service uses; we mock it so the
4
+ // tests run hermetically without an HTTP server. Each mocked response
5
+ // follows the SDK's `{ data, error }` envelope.
6
+ const cmsApiMock = vi.fn()
7
+ vi.mock('../../lib/api.js', () => ({
8
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
9
+ }))
10
+
11
+ const { fetchPosts, fetchPostTypes } = await import('../../lib/posts-service.js')
12
+
13
+ interface DocFixture {
14
+ id: string
15
+ collection: string
16
+ title: string
17
+ slug: string
18
+ status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'
19
+ createdById: string
20
+ updatedById: string
21
+ publishedAt: string | null
22
+ scheduledAt: string | null
23
+ createdAt: string
24
+ updatedAt: string
25
+ data: Record<string, unknown> | null
26
+ }
27
+
28
+ function doc(
29
+ o: Partial<DocFixture> & Pick<DocFixture, 'id' | 'collection' | 'title' | 'slug'>,
30
+ ): DocFixture {
31
+ return {
32
+ status: 'PUBLISHED',
33
+ createdById: 'u-david',
34
+ updatedById: 'u-david',
35
+ publishedAt: '2026-05-10T00:00:00.000Z',
36
+ scheduledAt: null,
37
+ createdAt: '2026-05-10T00:00:00.000Z',
38
+ updatedAt: '2026-05-10T00:00:00.000Z',
39
+ data: { seoScore: 80 },
40
+ ...o,
41
+ }
42
+ }
43
+
44
+ const COLLECTIONS_RESPONSE = [
45
+ {
46
+ slug: 'blog',
47
+ labels: { singular: 'Blog Post', plural: 'Blog' },
48
+ type: 'post',
49
+ urlPrefix: 'blog',
50
+ description: 'Blog posts',
51
+ icon: 'file-text',
52
+ color: 'purple',
53
+ group: 'Posts',
54
+ hidden: false,
55
+ fieldCount: 12,
56
+ },
57
+ {
58
+ slug: 'case-studies',
59
+ labels: { singular: 'Case Study', plural: 'Case Studies' },
60
+ type: 'post',
61
+ urlPrefix: 'case-studies',
62
+ description: 'Case studies',
63
+ icon: 'briefcase',
64
+ color: 'green',
65
+ group: 'Posts',
66
+ hidden: false,
67
+ fieldCount: 13,
68
+ },
69
+ {
70
+ slug: 'news',
71
+ labels: { singular: 'News Item', plural: 'News' },
72
+ type: 'post',
73
+ urlPrefix: 'news',
74
+ description: 'News',
75
+ icon: 'newspaper',
76
+ color: 'orange',
77
+ group: 'Posts',
78
+ hidden: false,
79
+ fieldCount: 11,
80
+ },
81
+ {
82
+ slug: 'pages',
83
+ labels: { singular: 'Page', plural: 'Pages' },
84
+ type: 'page',
85
+ urlPrefix: '',
86
+ description: null,
87
+ icon: null,
88
+ color: null,
89
+ group: null,
90
+ hidden: false,
91
+ fieldCount: 5,
92
+ },
93
+ ]
94
+
95
+ const USERS_RESPONSE = {
96
+ users: [
97
+ { id: 'u-sarah', name: 'Sarah Morgan', email: 'sarah@example.com' },
98
+ { id: 'u-maria', name: 'Maria K', email: 'maria@example.com' },
99
+ { id: 'u-david', name: 'David Carlson', email: 'david@example.com' },
100
+ ],
101
+ }
102
+
103
+ const DOCS: Record<string, DocFixture[]> = {
104
+ blog: [
105
+ doc({
106
+ id: 'b1',
107
+ collection: 'blog',
108
+ title: 'The Future of AI Content Marketing',
109
+ slug: 'ai-content-marketing',
110
+ createdById: 'u-maria',
111
+ updatedById: 'u-maria',
112
+ updatedAt: '2026-05-18T00:00:00.000Z',
113
+ data: { seoScore: 88 },
114
+ }),
115
+ doc({
116
+ id: 'b2',
117
+ collection: 'blog',
118
+ title: 'ActuateCMS vs Contentful',
119
+ slug: 'actuatecms-vs-contentful',
120
+ updatedAt: '2026-05-15T00:00:00.000Z',
121
+ data: { seoScore: 92 },
122
+ }),
123
+ doc({
124
+ id: 'b3',
125
+ collection: 'blog',
126
+ title: 'Mid-Year Review',
127
+ slug: 'mid-year-cms-trends',
128
+ status: 'DRAFT',
129
+ createdById: 'u-sarah',
130
+ updatedById: 'u-sarah',
131
+ publishedAt: null,
132
+ updatedAt: '2026-05-22T00:00:00.000Z',
133
+ data: { seoScore: null },
134
+ }),
135
+ ],
136
+ 'case-studies': [
137
+ doc({
138
+ id: 'c1',
139
+ collection: 'case-studies',
140
+ title: 'Healthcare Client SEO Results',
141
+ slug: 'healthcare-seo',
142
+ status: 'DRAFT',
143
+ publishedAt: null,
144
+ updatedAt: '2026-05-19T00:00:00.000Z',
145
+ data: { seoScore: 38 },
146
+ }),
147
+ ],
148
+ news: [
149
+ doc({
150
+ id: 'n1',
151
+ collection: 'news',
152
+ title: 'Q2 2026 Product Roadmap Update',
153
+ slug: 'q2-roadmap',
154
+ status: 'SCHEDULED',
155
+ createdById: 'u-sarah',
156
+ updatedById: 'u-sarah',
157
+ publishedAt: null,
158
+ scheduledAt: '2026-06-01T00:00:00.000Z',
159
+ updatedAt: '2026-05-24T00:00:00.000Z',
160
+ data: { seoScore: 62 },
161
+ }),
162
+ ],
163
+ }
164
+
165
+ function installDefaultMocks() {
166
+ cmsApiMock.mockImplementation((url: string) => {
167
+ if (url === '/collections') return { data: COLLECTIONS_RESPONSE }
168
+ if (url.startsWith('/users')) return { data: USERS_RESPONSE }
169
+ const m = url.match(/^\/collections\/([^/?]+)/)
170
+ if (m && m[1]) {
171
+ const slug = decodeURIComponent(m[1])
172
+ return { data: { docs: DOCS[slug] ?? [], total: DOCS[slug]?.length ?? 0 } }
173
+ }
174
+ return { error: `unexpected url: ${url}` }
175
+ })
176
+ }
177
+
178
+ describe('posts-service.fetchPostTypes', () => {
179
+ beforeEach(() => {
180
+ cmsApiMock.mockReset()
181
+ installDefaultMocks()
182
+ })
183
+
184
+ it('returns only post-typed collections (filters out type: page)', async () => {
185
+ const types = await fetchPostTypes()
186
+ expect(types.map((t) => t.slug).sort()).toEqual(['blog', 'case-studies', 'news'])
187
+ })
188
+
189
+ it('maps collection metadata onto the PostType shape', async () => {
190
+ const types = await fetchPostTypes()
191
+ const blog = types.find((t) => t.slug === 'blog')
192
+ expect(blog).toMatchObject({
193
+ id: 'blog',
194
+ slug: 'blog',
195
+ name: 'Blog',
196
+ singularName: 'Blog Post',
197
+ description: 'Blog posts',
198
+ urlBase: '/blog',
199
+ accentColor: 'purple',
200
+ icon: 'file-text',
201
+ fieldCount: 12,
202
+ active: true,
203
+ })
204
+ // postCount is 0 here because fetchPostTypes doesn't load docs.
205
+ expect(blog?.postCount).toBe(0)
206
+ })
207
+ })
208
+
209
+ describe('posts-service.fetchPosts', () => {
210
+ beforeEach(() => {
211
+ cmsApiMock.mockReset()
212
+ installDefaultMocks()
213
+ })
214
+
215
+ it('aggregates posts across all post-typed collections', async () => {
216
+ const res = await fetchPosts({ pageSize: 20 })
217
+ expect(res.total).toBe(5)
218
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b1', 'b2', 'b3', 'c1', 'n1'])
219
+ })
220
+
221
+ it('joins authors via /users', async () => {
222
+ const res = await fetchPosts({ pageSize: 20 })
223
+ const ai = res.posts.find((p) => p.id === 'b1')
224
+ expect(ai?.authorName).toBe('Maria K')
225
+ expect(ai?.authorInitials).toBe('MK')
226
+ })
227
+
228
+ it('sorts by updated date desc by default and surfaces scheduled date for SCHEDULED posts', async () => {
229
+ const res = await fetchPosts({ pageSize: 20 })
230
+ // The SCHEDULED news post should win the "date" sort because its
231
+ // scheduledAt is the most recent date in the fixture set.
232
+ expect(res.posts[0]?.id).toBe('n1')
233
+ expect(res.posts[0]?.publishDate).toBe('2026-06-01T00:00:00.000Z')
234
+ })
235
+
236
+ it('filters by postType', async () => {
237
+ const res = await fetchPosts({ postType: 'blog', pageSize: 20 })
238
+ expect(res.total).toBe(3)
239
+ expect(res.posts.every((p) => p.postTypeId === 'blog')).toBe(true)
240
+ })
241
+
242
+ it('filters by status', async () => {
243
+ const res = await fetchPosts({ status: 'DRAFT', pageSize: 20 })
244
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'c1'])
245
+ })
246
+
247
+ it('filters by author', async () => {
248
+ const res = await fetchPosts({ author: 'u-sarah', pageSize: 20 })
249
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'n1'])
250
+ })
251
+
252
+ it('searches across title, slug, author name, and post type', async () => {
253
+ const res = await fetchPosts({ search: 'roadmap' })
254
+ expect(res.posts).toHaveLength(1)
255
+ expect(res.posts[0]?.id).toBe('n1')
256
+ })
257
+
258
+ it('paginates and exposes the unfiltered tab counts', async () => {
259
+ const res = await fetchPosts({ pageSize: 2 })
260
+ expect(res.posts).toHaveLength(2)
261
+ expect(res.total).toBe(5)
262
+ const blogTab = res.types.find((t) => t.slug === 'blog')
263
+ expect(blogTab?.postCount).toBe(3)
264
+ const newsTab = res.types.find((t) => t.slug === 'news')
265
+ expect(newsTab?.postCount).toBe(1)
266
+ })
267
+
268
+ it('returns only authors actually present on posts', async () => {
269
+ const res = await fetchPosts({ pageSize: 20 })
270
+ expect(res.authors.map((a) => a.id).sort()).toEqual(['u-david', 'u-maria', 'u-sarah'])
271
+ })
272
+
273
+ it('uses the postType filter to scope the result set without touching the tab counts', async () => {
274
+ const res = await fetchPosts({ postType: 'news', pageSize: 20 })
275
+ expect(res.total).toBe(1)
276
+ // Tab counts still reflect the full corpus.
277
+ expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
278
+ })
279
+ })
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pageNumbers } from '../../views/Posts/PostsListView.js'
3
+ import { renderSnippet } from '../../views/Posts/NewPostTypeInstructions.js'
4
+ import { POST_TYPE_PRESETS } from '../../views/Posts/post-type-presets.js'
5
+ import { POST_TYPE_ICONS } from '../../components/posts/icons.js'
6
+
7
+ /**
8
+ * `pageNumbers` is the only pure helper exported from PostsListView.
9
+ * It drives the pagination footer's sliding window so the bug-prone
10
+ * cases (single page, exact-window boundary, ellipsis on either end)
11
+ * have explicit coverage.
12
+ */
13
+ describe('pageNumbers', () => {
14
+ it('returns 1..N when total ≤ 7', () => {
15
+ expect(pageNumbers(1, 1)).toEqual([1])
16
+ expect(pageNumbers(3, 5)).toEqual([1, 2, 3, 4, 5])
17
+ expect(pageNumbers(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
18
+ })
19
+
20
+ it('shows leading and trailing ellipses for a mid-range page', () => {
21
+ expect(pageNumbers(50, 100)).toEqual([1, '…', 49, 50, 51, '…', 100])
22
+ })
23
+
24
+ it('hides leading ellipsis when current is near the start', () => {
25
+ expect(pageNumbers(2, 20)).toEqual([1, 2, 3, '…', 20])
26
+ })
27
+
28
+ it('hides trailing ellipsis when current is near the end', () => {
29
+ expect(pageNumbers(19, 20)).toEqual([1, '…', 18, 19, 20])
30
+ })
31
+ })
32
+
33
+ describe('renderSnippet', () => {
34
+ const base = {
35
+ slug: 'reviews',
36
+ singular: 'Review',
37
+ plural: 'Reviews',
38
+ color: 'purple' as const,
39
+ icon: 'file-text' as const,
40
+ description: 'Reviews',
41
+ }
42
+
43
+ it('emits a valid-looking collection entry with the slug, labels, and admin metadata', () => {
44
+ const out = renderSnippet({ ...base, extraFields: [] })
45
+ expect(out).toContain("'reviews': {")
46
+ expect(out).toContain("slug: 'reviews'")
47
+ expect(out).toContain("singular: 'Review'")
48
+ expect(out).toContain("type: 'post'")
49
+ expect(out).toContain("group: 'Posts'")
50
+ expect(out).toContain("color: 'purple'")
51
+ // No type-specific block when there are no extra fields.
52
+ expect(out).not.toContain('type-specific fields')
53
+ })
54
+
55
+ it('injects preset extra fields after the shared base fields', () => {
56
+ const out = renderSnippet({
57
+ ...base,
58
+ extraFields: ["client: { type: 'text', label: 'Client Name' },"],
59
+ })
60
+ expect(out).toContain('// type-specific fields')
61
+ expect(out).toContain("client: { type: 'text', label: 'Client Name' },")
62
+ // Base fields are still present alongside the injected ones.
63
+ expect(out).toContain("title: { type: 'text', label: 'Title', required: true },")
64
+ expect(out).toContain("metaDescription: { type: 'text', label: 'SEO Description' },")
65
+ })
66
+
67
+ it('escapes single quotes in free-text fields so the snippet stays valid', () => {
68
+ const out = renderSnippet({
69
+ ...base,
70
+ description: "Editors' picks",
71
+ extraFields: [],
72
+ })
73
+ expect(out).toContain("description: 'Editors\\' picks'")
74
+ })
75
+
76
+ it('falls back to a placeholder slug when slug is empty', () => {
77
+ const out = renderSnippet({ ...base, slug: '', extraFields: [] })
78
+ expect(out).toContain("'new-type': {")
79
+ })
80
+ })
81
+
82
+ describe('POST_TYPE_PRESETS', () => {
83
+ it('has unique keys and slugs', () => {
84
+ const keys = POST_TYPE_PRESETS.map((p) => p.key)
85
+ const slugs = POST_TYPE_PRESETS.map((p) => p.slug)
86
+ expect(new Set(keys).size).toBe(keys.length)
87
+ expect(new Set(slugs).size).toBe(slugs.length)
88
+ })
89
+
90
+ it('only references icons that exist in the shared icon registry', () => {
91
+ for (const preset of POST_TYPE_PRESETS) {
92
+ expect(POST_TYPE_ICONS[preset.icon]).toBeDefined()
93
+ }
94
+ })
95
+
96
+ it('uses only palette colors the badge component understands', () => {
97
+ const allowed = new Set(['purple', 'green', 'orange', 'blue', 'red', 'pink'])
98
+ for (const preset of POST_TYPE_PRESETS) {
99
+ expect(allowed.has(preset.color)).toBe(true)
100
+ }
101
+ })
102
+
103
+ it('every preset extra field line is a complete key: { … }, definition', () => {
104
+ for (const preset of POST_TYPE_PRESETS) {
105
+ for (const line of preset.extraFields) {
106
+ expect(line).toMatch(/^[a-zA-Z][\w]*: \{.*\},$/)
107
+ }
108
+ }
109
+ })
110
+ })
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import { paletteFor } from '../posts/badges.js'
4
+
5
+ /**
6
+ * Tag UI for the Pages area. Two variants:
7
+ * - PageTagChip — interactive filter chip (filter bar). Toggles a tag
8
+ * on/off; announces pressed state for screen readers.
9
+ * - PageTagPill — static inline pill (Tags column). Compact, with a
10
+ * colored dot so tags don't rely on color alone.
11
+ *
12
+ * Both resolve color through the shared `paletteFor` palette so they
13
+ * stay consistent with Post Type badges and work in light/dark mode.
14
+ */
15
+
16
+ const DOT_COLOR: Record<string, string> = {
17
+ purple: 'bg-violet-500',
18
+ green: 'bg-emerald-500',
19
+ orange: 'bg-amber-500',
20
+ red: 'bg-red-500',
21
+ blue: 'bg-blue-500',
22
+ pink: 'bg-pink-500',
23
+ gray: 'bg-muted-foreground',
24
+ }
25
+
26
+ export function tagDotColor(color: string | null | undefined): string {
27
+ if (!color) return DOT_COLOR.gray!
28
+ return DOT_COLOR[color] ?? DOT_COLOR.gray!
29
+ }
30
+
31
+ export function PageTagChip({
32
+ name,
33
+ color,
34
+ count,
35
+ selected,
36
+ onToggle,
37
+ }: {
38
+ name: string
39
+ color: string | null
40
+ count?: number
41
+ selected: boolean
42
+ onToggle: () => void
43
+ }) {
44
+ const p = paletteFor(color)
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={onToggle}
49
+ aria-pressed={selected}
50
+ className={`focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 ${
51
+ selected
52
+ ? `${p.bg} ${p.text} border-transparent ring-1 ${p.ring}`
53
+ : 'border-border bg-background text-foreground hover:bg-accent'
54
+ }`}
55
+ >
56
+ <span aria-hidden className={`h-2 w-2 rounded-full ${tagDotColor(color)}`} />
57
+ {name}
58
+ {typeof count === 'number' && (
59
+ <span className="text-muted-foreground tabular-nums">{count}</span>
60
+ )}
61
+ </button>
62
+ )
63
+ }
64
+
65
+ export function PageTagPill({ name, color }: { name: string; color: string | null }) {
66
+ const p = paletteFor(color)
67
+ return (
68
+ <span
69
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${p.bg} ${p.text}`}
70
+ >
71
+ <span aria-hidden className={`h-1.5 w-1.5 rounded-full ${tagDotColor(color)}`} />
72
+ {name}
73
+ </span>
74
+ )
75
+ }
76
+
77
+ export function PageTagsCell({
78
+ tagSlugs,
79
+ primaryTagId,
80
+ resolve,
81
+ }: {
82
+ tagSlugs: string[]
83
+ primaryTagId: string | null
84
+ resolve: (slug: string) => { name: string; color: string | null } | undefined
85
+ }) {
86
+ if (tagSlugs.length === 0) {
87
+ return <span className="text-muted-foreground text-xs">—</span>
88
+ }
89
+ // Show the primary tag first, then the rest, so grouping context is
90
+ // visible at a glance.
91
+ const ordered = [...tagSlugs].sort((a, b) => {
92
+ if (a === primaryTagId) return -1
93
+ if (b === primaryTagId) return 1
94
+ return 0
95
+ })
96
+ return (
97
+ <div className="flex flex-wrap items-center gap-1">
98
+ {ordered.map((slug) => {
99
+ const tag = resolve(slug)
100
+ return <PageTagPill key={slug} name={tag?.name ?? slug} color={tag?.color ?? null} />
101
+ })}
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import type { PostStatus } from '../../lib/posts-service.js'
5
+
6
+ /**
7
+ * Shared status / type / SEO indicators for the Posts admin area.
8
+ *
9
+ * Colors are restricted to the theme tokens already used elsewhere in
10
+ * the admin (`success`, `warning`, `destructive`, `primary`, `muted`)
11
+ * plus a small named-color palette (`purple`, `green`, `orange`, ...)
12
+ * that maps to fixed Tailwind utilities. This keeps the badges working
13
+ * in both light and dark mode and avoids leaking `bg-[#...]` arbitrary
14
+ * values into components.
15
+ */
16
+
17
+ // ─── Named color palette ─────────────────────────────────────────────
18
+
19
+ const PALETTE: Record<string, { bg: string; text: string; ring: string; border: string }> = {
20
+ purple: {
21
+ bg: 'bg-violet-50 dark:bg-violet-950/40',
22
+ text: 'text-violet-700 dark:text-violet-300',
23
+ ring: 'ring-violet-200 dark:ring-violet-900',
24
+ border: 'border-violet-500',
25
+ },
26
+ green: {
27
+ bg: 'bg-emerald-50 dark:bg-emerald-950/40',
28
+ text: 'text-emerald-700 dark:text-emerald-300',
29
+ ring: 'ring-emerald-200 dark:ring-emerald-900',
30
+ border: 'border-emerald-500',
31
+ },
32
+ orange: {
33
+ bg: 'bg-amber-50 dark:bg-amber-950/40',
34
+ text: 'text-amber-700 dark:text-amber-300',
35
+ ring: 'ring-amber-200 dark:ring-amber-900',
36
+ border: 'border-amber-500',
37
+ },
38
+ red: {
39
+ bg: 'bg-red-50 dark:bg-red-950/40',
40
+ text: 'text-red-700 dark:text-red-300',
41
+ ring: 'ring-red-200 dark:ring-red-900',
42
+ border: 'border-red-500',
43
+ },
44
+ blue: {
45
+ bg: 'bg-blue-50 dark:bg-blue-950/40',
46
+ text: 'text-blue-700 dark:text-blue-300',
47
+ ring: 'ring-blue-200 dark:ring-blue-900',
48
+ border: 'border-blue-500',
49
+ },
50
+ pink: {
51
+ bg: 'bg-pink-50 dark:bg-pink-950/40',
52
+ text: 'text-pink-700 dark:text-pink-300',
53
+ ring: 'ring-pink-200 dark:ring-pink-900',
54
+ border: 'border-pink-500',
55
+ },
56
+ gray: {
57
+ bg: 'bg-muted',
58
+ text: 'text-muted-foreground',
59
+ ring: 'ring-border',
60
+ border: 'border-border',
61
+ },
62
+ }
63
+
64
+ export function paletteFor(color: string | null | undefined) {
65
+ if (!color) return PALETTE.gray!
66
+ return PALETTE[color] ?? PALETTE.gray!
67
+ }
68
+
69
+ // ─── PostStatusBadge ─────────────────────────────────────────────────
70
+
71
+ const STATUS_LABEL: Record<PostStatus, string> = {
72
+ PUBLISHED: 'Published',
73
+ DRAFT: 'Draft',
74
+ SCHEDULED: 'Scheduled',
75
+ ARCHIVED: 'Archived',
76
+ }
77
+
78
+ const STATUS_PALETTE: Record<PostStatus, string> = {
79
+ PUBLISHED: 'green',
80
+ DRAFT: 'gray',
81
+ SCHEDULED: 'orange',
82
+ ARCHIVED: 'gray',
83
+ }
84
+
85
+ const STATUS_GLYPH: Record<PostStatus, string> = {
86
+ PUBLISHED: '●',
87
+ DRAFT: '○',
88
+ SCHEDULED: '◐',
89
+ ARCHIVED: '◌',
90
+ }
91
+
92
+ export function PostStatusBadge({ status }: { status: PostStatus }) {
93
+ const p = paletteFor(STATUS_PALETTE[status])
94
+ return (
95
+ <span
96
+ className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${p.bg} ${p.text}`}
97
+ aria-label={`Status: ${STATUS_LABEL[status]}`}
98
+ >
99
+ <span aria-hidden className="text-[10px] leading-none">
100
+ {STATUS_GLYPH[status]}
101
+ </span>
102
+ {STATUS_LABEL[status]}
103
+ </span>
104
+ )
105
+ }
106
+
107
+ // ─── PostTypeBadge ───────────────────────────────────────────────────
108
+
109
+ export function PostTypeBadge({
110
+ label,
111
+ color,
112
+ onClick,
113
+ }: {
114
+ label: string
115
+ color: string | null
116
+ onClick?: () => void
117
+ }): ReactNode {
118
+ const p = paletteFor(color)
119
+ const className = `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${p.bg} ${p.text}`
120
+ if (onClick) {
121
+ return (
122
+ <button type="button" onClick={onClick} className={`${className} hover:opacity-80`}>
123
+ {label}
124
+ </button>
125
+ )
126
+ }
127
+ return <span className={className}>{label}</span>
128
+ }
129
+
130
+ // ─── SeoScoreIndicator ───────────────────────────────────────────────
131
+
132
+ /**
133
+ * Returns the palette key for an SEO score so callers can match the
134
+ * colored dot. 80+ green, 60–79 orange, 1–59 red, missing = gray.
135
+ */
136
+ export function seoScorePalette(score: number | null): string {
137
+ if (score === null || Number.isNaN(score)) return 'gray'
138
+ if (score >= 80) return 'green'
139
+ if (score >= 60) return 'orange'
140
+ if (score >= 1) return 'red'
141
+ return 'gray'
142
+ }
143
+
144
+ export function SeoScoreIndicator({ score }: { score: number | null }) {
145
+ const colorKey = seoScorePalette(score)
146
+ const p = paletteFor(colorKey)
147
+ if (score === null) {
148
+ return (
149
+ <span className="text-muted-foreground inline-flex items-center gap-1.5 text-xs">
150
+ <span aria-hidden className={`h-2 w-2 rounded-full ${p.bg.split(' ')[0]}`} />
151
+ <span className="tabular-nums">—</span>
152
+ </span>
153
+ )
154
+ }
155
+ return (
156
+ <span className="text-foreground inline-flex items-center gap-1.5 text-xs">
157
+ <span
158
+ aria-hidden
159
+ className={`h-2 w-2 rounded-full ${
160
+ colorKey === 'green'
161
+ ? 'bg-emerald-500'
162
+ : colorKey === 'orange'
163
+ ? 'bg-amber-500'
164
+ : 'bg-red-500'
165
+ }`}
166
+ />
167
+ <span className="tabular-nums">{score}</span>
168
+ </span>
169
+ )
170
+ }
@@ -0,0 +1,40 @@
1
+ import {
2
+ FileText,
3
+ Briefcase,
4
+ Newspaper,
5
+ BookOpen,
6
+ Folder,
7
+ Layers,
8
+ Image as ImageIcon,
9
+ Calendar,
10
+ Users,
11
+ HelpCircle,
12
+ type LucideIcon,
13
+ } from 'lucide-react'
14
+
15
+ /**
16
+ * Icon registry for Post Types. Keyed by the string value stored in a
17
+ * collection's `admin.icon`. Rendering surfaces (Post Types cards, the
18
+ * New Post picker, the Quick Post Type gallery) all resolve through
19
+ * `resolvePostTypeIcon` so a single map keeps them in sync — adding an
20
+ * icon here makes it available everywhere at once.
21
+ */
22
+ export const POST_TYPE_ICONS: Record<string, LucideIcon> = {
23
+ 'file-text': FileText,
24
+ briefcase: Briefcase,
25
+ newspaper: Newspaper,
26
+ book: BookOpen,
27
+ folder: Folder,
28
+ layers: Layers,
29
+ image: ImageIcon,
30
+ calendar: Calendar,
31
+ users: Users,
32
+ help: HelpCircle,
33
+ }
34
+
35
+ export type PostTypeIconName = keyof typeof POST_TYPE_ICONS
36
+
37
+ export function resolvePostTypeIcon(name: string | null | undefined): LucideIcon {
38
+ if (!name) return FileText
39
+ return POST_TYPE_ICONS[name.toLowerCase()] ?? FileText
40
+ }