@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,869 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import {
5
+ ChevronDown,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ ChevronUp,
9
+ Loader2,
10
+ Search,
11
+ Settings,
12
+ Trash2,
13
+ X,
14
+ } from 'lucide-react'
15
+ import { toast } from 'sonner'
16
+ import {
17
+ bulkDeletePosts,
18
+ bulkUpdatePosts,
19
+ fetchPosts,
20
+ type Post,
21
+ type PostStatus,
22
+ type PostsResult,
23
+ type PostType,
24
+ } from '../../lib/posts-service.js'
25
+ import {
26
+ PostStatusBadge,
27
+ PostTypeBadge,
28
+ SeoScoreIndicator,
29
+ paletteFor,
30
+ } from '../../components/posts/badges.js'
31
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
32
+
33
+ /**
34
+ * All Posts management surface — table with type tabs, controls row,
35
+ * sortable headers, bulk actions, pagination. Data flows through the
36
+ * posts-service so swapping to a server endpoint later only touches
37
+ * the service file.
38
+ *
39
+ * Routing:
40
+ * - Row click → /posts/{postTypeId}/{postId}/edit (the section-based Post
41
+ * editor, seeded from the post type's template).
42
+ * - "Manage types" → /posts/types.
43
+ * - "+ New Post" lives in the top bar (computePageActions); the page
44
+ * itself doesn't render that button.
45
+ */
46
+
47
+ interface PostsListViewProps {
48
+ onNavigate: (path: string) => void
49
+ }
50
+
51
+ type SortBy = 'title' | 'type' | 'author' | 'status' | 'date' | 'seo'
52
+ type Dir = 'asc' | 'desc'
53
+
54
+ const PAGE_SIZE = 10
55
+
56
+ export function PostsListView({ onNavigate }: PostsListViewProps) {
57
+ const [activeType, setActiveType] = useState<string>('all')
58
+ const [search, setSearch] = useState('')
59
+ const [debouncedSearch, setDebouncedSearch] = useState('')
60
+ const [status, setStatus] = useState<PostStatus | 'all'>('all')
61
+ const [author, setAuthor] = useState<string>('all')
62
+ const [sortBy, setSortBy] = useState<SortBy>('date')
63
+ const [dir, setDir] = useState<Dir>('desc')
64
+ const [page, setPage] = useState(1)
65
+ const [selected, setSelected] = useState<Set<string>>(new Set())
66
+
67
+ const [data, setData] = useState<PostsResult | null>(null)
68
+ const [loading, setLoading] = useState(true)
69
+ const [error, setError] = useState<string | null>(null)
70
+ const [refreshToken, setRefreshToken] = useState(0)
71
+ const [pendingBulk, setPendingBulk] = useState<null | 'delete' | 'publish' | 'draft'>(null)
72
+
73
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
74
+ useEffect(() => {
75
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
76
+ debounceTimer.current = setTimeout(() => {
77
+ setDebouncedSearch(search.trim())
78
+ setPage(1)
79
+ }, 250)
80
+ return () => {
81
+ if (debounceTimer.current) clearTimeout(debounceTimer.current)
82
+ }
83
+ }, [search])
84
+
85
+ // Re-fetch when any input that affects the result set changes.
86
+ useEffect(() => {
87
+ let cancelled = false
88
+ setLoading(true)
89
+ setError(null)
90
+ fetchPosts({
91
+ postType: activeType,
92
+ status,
93
+ author,
94
+ search: debouncedSearch,
95
+ sortBy,
96
+ sortDirection: dir,
97
+ page,
98
+ pageSize: PAGE_SIZE,
99
+ })
100
+ .then((res) => {
101
+ if (cancelled) return
102
+ setData(res)
103
+ setLoading(false)
104
+ })
105
+ .catch((err: unknown) => {
106
+ if (cancelled) return
107
+ setError(err instanceof Error ? err.message : 'Failed to load posts')
108
+ setLoading(false)
109
+ })
110
+ return () => {
111
+ cancelled = true
112
+ }
113
+ }, [activeType, status, author, debouncedSearch, sortBy, dir, page, refreshToken])
114
+
115
+ // Whenever the active type tab changes, reset paging to page 1.
116
+ useEffect(() => {
117
+ setPage(1)
118
+ setSelected(new Set())
119
+ }, [activeType, debouncedSearch, status, author])
120
+
121
+ const total = data?.total ?? 0
122
+ const totalAll = useMemo(
123
+ () => (data?.types ?? []).reduce((sum, t) => sum + t.postCount, 0),
124
+ [data?.types],
125
+ )
126
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
127
+ const posts = data?.posts ?? []
128
+ const types = data?.types ?? []
129
+ const authors = data?.authors ?? []
130
+
131
+ const handleSort = useCallback(
132
+ (field: SortBy) => {
133
+ if (sortBy === field) {
134
+ setDir(dir === 'asc' ? 'desc' : 'asc')
135
+ } else {
136
+ setSortBy(field)
137
+ setDir(field === 'date' || field === 'seo' ? 'desc' : 'asc')
138
+ }
139
+ },
140
+ [sortBy, dir],
141
+ )
142
+
143
+ const clearFilters = () => {
144
+ setSearch('')
145
+ setStatus('all')
146
+ setAuthor('all')
147
+ setActiveType('all')
148
+ setPage(1)
149
+ }
150
+
151
+ const filtersAreActive =
152
+ debouncedSearch !== '' || status !== 'all' || author !== 'all' || activeType !== 'all'
153
+
154
+ // ─── Selection / bulk actions ─────────────────────────────────────
155
+
156
+ const toggleOne = (id: string) =>
157
+ setSelected((s) => {
158
+ const next = new Set(s)
159
+ if (next.has(id)) next.delete(id)
160
+ else next.add(id)
161
+ return next
162
+ })
163
+
164
+ const allOnPageSelected = posts.length > 0 && posts.every((p) => selected.has(p.id))
165
+ const toggleAllOnPage = (checked: boolean) =>
166
+ setSelected((s) => {
167
+ const next = new Set(s)
168
+ if (checked) for (const p of posts) next.add(p.id)
169
+ else for (const p of posts) next.delete(p.id)
170
+ return next
171
+ })
172
+
173
+ const selectedPosts = useMemo(() => posts.filter((p) => selected.has(p.id)), [posts, selected])
174
+
175
+ const runBulkUpdate = async (target: PostStatus) => {
176
+ const ids = selectedPosts.map((p) => ({ postTypeId: p.postTypeId, id: p.id }))
177
+ if (ids.length === 0) return
178
+ const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
179
+ const promise = bulkUpdatePosts(ids, { status: target })
180
+ toast.promise(promise, {
181
+ loading: `${verb} ${ids.length} post${ids.length === 1 ? '' : 's'}…`,
182
+ success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
183
+ error: 'Bulk update failed',
184
+ })
185
+ await promise
186
+ setSelected(new Set())
187
+ setRefreshToken((t) => t + 1)
188
+ }
189
+
190
+ const runBulkDelete = async () => {
191
+ const ids = selectedPosts.map((p) => ({ postTypeId: p.postTypeId, id: p.id }))
192
+ if (ids.length === 0) return
193
+ const promise = bulkDeletePosts(ids)
194
+ toast.promise(promise, {
195
+ loading: `Deleting ${ids.length} post${ids.length === 1 ? '' : 's'}…`,
196
+ success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
197
+ error: 'Bulk delete failed',
198
+ })
199
+ await promise
200
+ setSelected(new Set())
201
+ setRefreshToken((t) => t + 1)
202
+ }
203
+
204
+ // ─── Render ───────────────────────────────────────────────────────
205
+
206
+ return (
207
+ <div className="flex h-full flex-col gap-4 p-4 sm:p-6">
208
+ {/* Header */}
209
+ <div className="flex flex-col gap-1">
210
+ <h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">Posts</h1>
211
+ <p className="text-muted-foreground text-sm">
212
+ {totalAll} total post{totalAll === 1 ? '' : 's'} across all types
213
+ </p>
214
+ </div>
215
+
216
+ {/* Type tabs */}
217
+ <TypeTabs types={types} totalAll={totalAll} active={activeType} onChange={setActiveType} />
218
+
219
+ {/* Controls row */}
220
+ <ControlsRow
221
+ search={search}
222
+ onSearchChange={setSearch}
223
+ status={status}
224
+ onStatusChange={(s) => {
225
+ setStatus(s)
226
+ setPage(1)
227
+ }}
228
+ author={author}
229
+ onAuthorChange={(a) => {
230
+ setAuthor(a)
231
+ setPage(1)
232
+ }}
233
+ authors={authors}
234
+ resultCount={total}
235
+ onManageTypes={() => onNavigate('/posts/types')}
236
+ />
237
+
238
+ {/* Bulk action bar */}
239
+ {selected.size > 0 && (
240
+ <BulkActionBar
241
+ count={selected.size}
242
+ onClear={() => setSelected(new Set())}
243
+ onPublish={() => runBulkUpdate('PUBLISHED')}
244
+ onDraft={() => runBulkUpdate('DRAFT')}
245
+ onDelete={() => setPendingBulk('delete')}
246
+ />
247
+ )}
248
+
249
+ {/* Table card */}
250
+ <div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
251
+ <table className="w-full table-fixed" role="table" aria-label="Posts">
252
+ <colgroup>
253
+ <col style={{ width: '40px' }} />
254
+ <col />
255
+ <col style={{ width: '140px' }} />
256
+ <col style={{ width: '180px' }} />
257
+ <col style={{ width: '130px' }} />
258
+ <col style={{ width: '120px' }} />
259
+ <col style={{ width: '90px' }} />
260
+ </colgroup>
261
+ <thead className="border-border bg-muted/40 border-b">
262
+ <tr className="text-muted-foreground text-xs">
263
+ <th scope="col" className="px-3 py-2.5 text-left">
264
+ <input
265
+ type="checkbox"
266
+ aria-label="Select all posts on this page"
267
+ checked={allOnPageSelected}
268
+ onChange={(e) => toggleAllOnPage(e.target.checked)}
269
+ className="border-border accent-primary rounded"
270
+ />
271
+ </th>
272
+ <th scope="col" className="px-3 py-2.5 text-left">
273
+ <SortHeader
274
+ label="Title"
275
+ field="title"
276
+ current={sortBy}
277
+ dir={dir}
278
+ onSort={handleSort}
279
+ />
280
+ </th>
281
+ <th scope="col" className="px-3 py-2.5 text-left">
282
+ <SortHeader
283
+ label="Type"
284
+ field="type"
285
+ current={sortBy}
286
+ dir={dir}
287
+ onSort={handleSort}
288
+ />
289
+ </th>
290
+ <th scope="col" className="px-3 py-2.5 text-left">
291
+ <SortHeader
292
+ label="Author"
293
+ field="author"
294
+ current={sortBy}
295
+ dir={dir}
296
+ onSort={handleSort}
297
+ />
298
+ </th>
299
+ <th scope="col" className="px-3 py-2.5 text-left">
300
+ <SortHeader
301
+ label="Status"
302
+ field="status"
303
+ current={sortBy}
304
+ dir={dir}
305
+ onSort={handleSort}
306
+ />
307
+ </th>
308
+ <th scope="col" className="px-3 py-2.5 text-left">
309
+ <SortHeader
310
+ label="Date"
311
+ field="date"
312
+ current={sortBy}
313
+ dir={dir}
314
+ onSort={handleSort}
315
+ />
316
+ </th>
317
+ <th scope="col" className="px-3 py-2.5 text-left">
318
+ <SortHeader
319
+ label="SEO"
320
+ field="seo"
321
+ current={sortBy}
322
+ dir={dir}
323
+ onSort={handleSort}
324
+ />
325
+ </th>
326
+ </tr>
327
+ </thead>
328
+ <tbody>
329
+ {loading && posts.length === 0 && <LoadingRows />}
330
+ {!loading && error && (
331
+ <tr>
332
+ <td colSpan={7} className="px-3 py-12 text-center">
333
+ <div className="text-foreground mb-2 text-sm font-medium">{error}</div>
334
+ <button
335
+ onClick={() => setRefreshToken((t) => t + 1)}
336
+ className="text-primary text-sm hover:underline"
337
+ >
338
+ Try again
339
+ </button>
340
+ </td>
341
+ </tr>
342
+ )}
343
+ {!loading && !error && posts.length === 0 && (
344
+ <EmptyRow
345
+ filtersActive={filtersAreActive}
346
+ onClear={clearFilters}
347
+ onNewPost={() => onNavigate('/posts/new')}
348
+ />
349
+ )}
350
+ {!loading &&
351
+ !error &&
352
+ posts.map((p) => (
353
+ <PostRow
354
+ key={p.id}
355
+ post={p}
356
+ selected={selected.has(p.id)}
357
+ onSelect={() => toggleOne(p.id)}
358
+ onOpen={() => onNavigate(`/posts/${p.postTypeId}/${p.id}/edit`)}
359
+ />
360
+ ))}
361
+ </tbody>
362
+ </table>
363
+
364
+ {/* Pagination footer */}
365
+ <PaginationFooter
366
+ page={page}
367
+ totalPages={totalPages}
368
+ total={total}
369
+ pageSize={PAGE_SIZE}
370
+ onPage={setPage}
371
+ />
372
+ </div>
373
+
374
+ <ConfirmDialog
375
+ open={pendingBulk === 'delete'}
376
+ title={`Delete ${selected.size} post${selected.size === 1 ? '' : 's'}?`}
377
+ description="This moves the selected posts to trash. They can be restored from the per-type list for 30 days."
378
+ confirmLabel="Delete"
379
+ destructive
380
+ onClose={() => setPendingBulk(null)}
381
+ onConfirm={() => {
382
+ void runBulkDelete()
383
+ }}
384
+ />
385
+ </div>
386
+ )
387
+ }
388
+
389
+ // ─── Sub-components ──────────────────────────────────────────────────
390
+
391
+ function SortHeader({
392
+ label,
393
+ field,
394
+ current,
395
+ dir,
396
+ onSort,
397
+ }: {
398
+ label: string
399
+ field: SortBy
400
+ current: SortBy
401
+ dir: Dir
402
+ onSort: (f: SortBy) => void
403
+ }) {
404
+ const active = current === field
405
+ const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
406
+ return (
407
+ <button
408
+ type="button"
409
+ onClick={() => onSort(field)}
410
+ aria-sort={ariaSort}
411
+ className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
412
+ active ? 'text-foreground' : 'text-muted-foreground'
413
+ }`}
414
+ >
415
+ <span>{label}</span>
416
+ {active &&
417
+ (dir === 'asc' ? (
418
+ <ChevronUp className="h-3 w-3" aria-hidden />
419
+ ) : (
420
+ <ChevronDown className="h-3 w-3" aria-hidden />
421
+ ))}
422
+ </button>
423
+ )
424
+ }
425
+
426
+ function TypeTabs({
427
+ types,
428
+ totalAll,
429
+ active,
430
+ onChange,
431
+ }: {
432
+ types: PostType[]
433
+ totalAll: number
434
+ active: string
435
+ onChange: (slug: string) => void
436
+ }) {
437
+ return (
438
+ <div
439
+ role="tablist"
440
+ aria-label="Filter posts by type"
441
+ className="border-border flex flex-wrap items-center gap-1 border-b pb-px"
442
+ >
443
+ <TabPill
444
+ label="All Posts"
445
+ count={totalAll}
446
+ active={active === 'all'}
447
+ onClick={() => onChange('all')}
448
+ />
449
+ {types.map((t) => (
450
+ <TabPill
451
+ key={t.slug}
452
+ label={t.name}
453
+ count={t.postCount}
454
+ active={active === t.slug}
455
+ onClick={() => onChange(t.slug)}
456
+ />
457
+ ))}
458
+ </div>
459
+ )
460
+ }
461
+
462
+ function TabPill({
463
+ label,
464
+ count,
465
+ active,
466
+ onClick,
467
+ }: {
468
+ label: string
469
+ count: number
470
+ active: boolean
471
+ onClick: () => void
472
+ }) {
473
+ return (
474
+ <button
475
+ type="button"
476
+ role="tab"
477
+ aria-selected={active}
478
+ onClick={onClick}
479
+ className={`relative inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
480
+ active
481
+ ? 'bg-primary/10 text-primary'
482
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
483
+ }`}
484
+ >
485
+ <span>{label}</span>
486
+ <span
487
+ className={`rounded-full px-1.5 text-xs tabular-nums ${
488
+ active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'
489
+ }`}
490
+ >
491
+ {count}
492
+ </span>
493
+ </button>
494
+ )
495
+ }
496
+
497
+ function ControlsRow({
498
+ search,
499
+ onSearchChange,
500
+ status,
501
+ onStatusChange,
502
+ author,
503
+ onAuthorChange,
504
+ authors,
505
+ resultCount,
506
+ onManageTypes,
507
+ }: {
508
+ search: string
509
+ onSearchChange: (v: string) => void
510
+ status: PostStatus | 'all'
511
+ onStatusChange: (s: PostStatus | 'all') => void
512
+ author: string
513
+ onAuthorChange: (a: string) => void
514
+ authors: { id: string; name: string }[]
515
+ resultCount: number
516
+ onManageTypes: () => void
517
+ }) {
518
+ return (
519
+ <div className="flex flex-wrap items-center gap-2">
520
+ <div className="relative max-w-sm flex-1">
521
+ <Search
522
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
523
+ aria-hidden
524
+ />
525
+ <input
526
+ type="search"
527
+ value={search}
528
+ onChange={(e) => onSearchChange(e.target.value)}
529
+ aria-label="Search posts"
530
+ placeholder="Search posts…"
531
+ className="border-border bg-card text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border py-2 pr-3 pl-9 text-sm shadow-sm focus:ring-2 focus:outline-none"
532
+ />
533
+ </div>
534
+
535
+ <FilterSelect
536
+ label="Status"
537
+ value={status}
538
+ onChange={(v) => onStatusChange(v as PostStatus | 'all')}
539
+ options={[
540
+ { value: 'all', label: 'All' },
541
+ { value: 'PUBLISHED', label: 'Published' },
542
+ { value: 'DRAFT', label: 'Draft' },
543
+ { value: 'SCHEDULED', label: 'Scheduled' },
544
+ ]}
545
+ />
546
+
547
+ <FilterSelect
548
+ label="Author"
549
+ value={author}
550
+ onChange={onAuthorChange}
551
+ options={[
552
+ { value: 'all', label: 'All' },
553
+ ...authors.map((a) => ({ value: a.id, label: a.name })),
554
+ ]}
555
+ />
556
+
557
+ <div className="ml-auto flex items-center gap-3">
558
+ <button
559
+ type="button"
560
+ onClick={onManageTypes}
561
+ className="border-border bg-background text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
562
+ >
563
+ <Settings className="h-3.5 w-3.5" aria-hidden />
564
+ Manage types
565
+ </button>
566
+ <span className="text-muted-foreground text-sm tabular-nums">
567
+ {resultCount} {resultCount === 1 ? 'result' : 'results'}
568
+ </span>
569
+ </div>
570
+ </div>
571
+ )
572
+ }
573
+
574
+ function FilterSelect({
575
+ label,
576
+ value,
577
+ onChange,
578
+ options,
579
+ }: {
580
+ label: string
581
+ value: string
582
+ onChange: (v: string) => void
583
+ options: { value: string; label: string }[]
584
+ }) {
585
+ return (
586
+ <label className="text-foreground inline-flex items-center gap-1.5 text-sm">
587
+ <span className="text-muted-foreground">{label}:</span>
588
+ <select
589
+ value={value}
590
+ onChange={(e) => onChange(e.target.value)}
591
+ className="border-border bg-card text-foreground focus:border-primary focus:ring-primary/30 rounded-lg border px-2.5 py-1.5 text-sm shadow-sm focus:ring-2 focus:outline-none"
592
+ >
593
+ {options.map((o) => (
594
+ <option key={o.value} value={o.value}>
595
+ {o.label}
596
+ </option>
597
+ ))}
598
+ </select>
599
+ </label>
600
+ )
601
+ }
602
+
603
+ function BulkActionBar({
604
+ count,
605
+ onClear,
606
+ onPublish,
607
+ onDraft,
608
+ onDelete,
609
+ }: {
610
+ count: number
611
+ onClear: () => void
612
+ onPublish: () => void
613
+ onDraft: () => void
614
+ onDelete: () => void
615
+ }) {
616
+ return (
617
+ <div className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-lg border px-4 py-2">
618
+ <span className="text-foreground text-sm font-medium">{count} selected</span>
619
+ <div className="bg-border h-4 w-px" aria-hidden />
620
+ <button
621
+ type="button"
622
+ onClick={onPublish}
623
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
624
+ >
625
+ Publish
626
+ </button>
627
+ <button
628
+ type="button"
629
+ onClick={onDraft}
630
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
631
+ >
632
+ Move to draft
633
+ </button>
634
+ <button
635
+ type="button"
636
+ onClick={onDelete}
637
+ className="text-destructive hover:bg-destructive/10 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
638
+ >
639
+ <Trash2 className="h-3.5 w-3.5" aria-hidden />
640
+ Delete
641
+ </button>
642
+ <button
643
+ type="button"
644
+ onClick={onClear}
645
+ className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
646
+ >
647
+ <X className="h-3.5 w-3.5" aria-hidden />
648
+ Clear
649
+ </button>
650
+ </div>
651
+ )
652
+ }
653
+
654
+ function PostRow({
655
+ post,
656
+ selected,
657
+ onSelect,
658
+ onOpen,
659
+ }: {
660
+ post: Post
661
+ selected: boolean
662
+ onSelect: () => void
663
+ onOpen: () => void
664
+ }) {
665
+ const p = paletteFor(post.postTypeAccentColor)
666
+ return (
667
+ <tr
668
+ className={`border-border hover:bg-muted/30 group border-b transition-colors last:border-b-0 ${
669
+ selected ? 'bg-primary/5' : ''
670
+ }`}
671
+ >
672
+ <td className="px-3 py-3" onClick={(e) => e.stopPropagation()}>
673
+ <input
674
+ type="checkbox"
675
+ aria-label={`Select "${post.title}"`}
676
+ checked={selected}
677
+ onChange={onSelect}
678
+ className="border-border accent-primary rounded"
679
+ />
680
+ </td>
681
+ <td className="px-3 py-3">
682
+ <button type="button" onClick={onOpen} className="flex w-full items-center gap-3 text-left">
683
+ <span aria-hidden className={`block h-9 w-9 shrink-0 rounded-md ${p.bg.split(' ')[0]}`} />
684
+ <span className="min-w-0">
685
+ <span className="text-foreground group-hover:text-primary block truncate text-sm font-medium">
686
+ {post.title}
687
+ </span>
688
+ <span className="text-muted-foreground block truncate text-xs">
689
+ /{post.postTypeId}/{post.slug || '—'}
690
+ </span>
691
+ </span>
692
+ </button>
693
+ </td>
694
+ <td className="px-3 py-3">
695
+ <PostTypeBadge label={post.postTypeName} color={post.postTypeAccentColor} />
696
+ </td>
697
+ <td className="px-3 py-3">
698
+ <div className="flex items-center gap-2">
699
+ <span
700
+ aria-hidden
701
+ className="bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold"
702
+ >
703
+ {post.authorInitials}
704
+ </span>
705
+ <span className="text-foreground truncate text-sm">{post.authorName}</span>
706
+ </div>
707
+ </td>
708
+ <td className="px-3 py-3">
709
+ <PostStatusBadge status={post.status} />
710
+ </td>
711
+ <td className="text-muted-foreground px-3 py-3 text-sm tabular-nums">
712
+ {formatDateShort(post.updatedDate)}
713
+ </td>
714
+ <td className="px-3 py-3">
715
+ <SeoScoreIndicator score={post.seoScore} />
716
+ </td>
717
+ </tr>
718
+ )
719
+ }
720
+
721
+ function LoadingRows() {
722
+ const rows = Array.from({ length: 8 })
723
+ return (
724
+ <>
725
+ {rows.map((_, i) => (
726
+ <tr key={i} className="border-border border-b last:border-b-0">
727
+ {Array.from({ length: 7 }).map((__, j) => (
728
+ <td key={j} className="px-3 py-3">
729
+ <div className="bg-muted h-4 w-full animate-pulse rounded" />
730
+ </td>
731
+ ))}
732
+ </tr>
733
+ ))}
734
+ </>
735
+ )
736
+ }
737
+
738
+ function EmptyRow({
739
+ filtersActive,
740
+ onClear,
741
+ onNewPost,
742
+ }: {
743
+ filtersActive: boolean
744
+ onClear: () => void
745
+ onNewPost: () => void
746
+ }) {
747
+ return (
748
+ <tr>
749
+ <td colSpan={7} className="px-3 py-16 text-center">
750
+ <div className="text-foreground mb-2 text-sm font-medium">
751
+ {filtersActive ? 'No posts match the current filters.' : 'No posts yet.'}
752
+ </div>
753
+ <div className="text-muted-foreground mb-4 text-sm">
754
+ {filtersActive
755
+ ? 'Try clearing the search or filters above.'
756
+ : 'Create your first post to get started.'}
757
+ </div>
758
+ {filtersActive ? (
759
+ <button type="button" onClick={onClear} className="text-primary text-sm hover:underline">
760
+ Clear filters
761
+ </button>
762
+ ) : (
763
+ <button
764
+ type="button"
765
+ onClick={onNewPost}
766
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors"
767
+ >
768
+ New Post
769
+ </button>
770
+ )}
771
+ </td>
772
+ </tr>
773
+ )
774
+ }
775
+
776
+ function PaginationFooter({
777
+ page,
778
+ totalPages,
779
+ total,
780
+ pageSize,
781
+ onPage,
782
+ }: {
783
+ page: number
784
+ totalPages: number
785
+ total: number
786
+ pageSize: number
787
+ onPage: (p: number) => void
788
+ }) {
789
+ const start = total === 0 ? 0 : (page - 1) * pageSize + 1
790
+ const end = Math.min(total, page * pageSize)
791
+
792
+ return (
793
+ <div className="border-border flex items-center justify-between border-t px-4 py-3 text-sm">
794
+ <span className="text-muted-foreground tabular-nums">
795
+ {start}–{end} of {total} post{total === 1 ? '' : 's'}
796
+ </span>
797
+ <nav className="flex items-center gap-1" aria-label="Posts pagination">
798
+ <button
799
+ type="button"
800
+ onClick={() => onPage(Math.max(1, page - 1))}
801
+ disabled={page === 1}
802
+ aria-label="Previous page"
803
+ className="border-border bg-background text-foreground hover:bg-accent inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-40"
804
+ >
805
+ <ChevronLeft className="h-4 w-4" aria-hidden />
806
+ </button>
807
+ {pageNumbers(page, totalPages).map((p, idx) =>
808
+ p === '…' ? (
809
+ <span
810
+ key={`gap-${idx}`}
811
+ aria-hidden
812
+ className="text-muted-foreground inline-flex h-8 w-8 items-center justify-center text-sm"
813
+ >
814
+
815
+ </span>
816
+ ) : (
817
+ <button
818
+ key={p}
819
+ type="button"
820
+ onClick={() => onPage(p)}
821
+ aria-current={p === page ? 'page' : undefined}
822
+ className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors ${
823
+ p === page
824
+ ? 'bg-primary text-primary-foreground'
825
+ : 'border-border bg-background text-foreground hover:bg-accent border'
826
+ }`}
827
+ >
828
+ {p}
829
+ </button>
830
+ ),
831
+ )}
832
+ <button
833
+ type="button"
834
+ onClick={() => onPage(Math.min(totalPages, page + 1))}
835
+ disabled={page === totalPages}
836
+ aria-label="Next page"
837
+ className="border-border bg-background text-foreground hover:bg-accent inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-40"
838
+ >
839
+ <ChevronRight className="h-4 w-4" aria-hidden />
840
+ </button>
841
+ </nav>
842
+ </div>
843
+ )
844
+ }
845
+
846
+ // ─── Helpers ─────────────────────────────────────────────────────────
847
+
848
+ function formatDateShort(input: string): string {
849
+ const d = new Date(input)
850
+ if (Number.isNaN(d.getTime())) return '—'
851
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
852
+ }
853
+
854
+ /**
855
+ * Return up to 7 page-control entries: a sliding window around the
856
+ * current page with ellipsis markers. Keeps the footer narrow even on
857
+ * collections with hundreds of pages.
858
+ */
859
+ export function pageNumbers(current: number, total: number): Array<number | '…'> {
860
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
861
+ const out: Array<number | '…'> = [1]
862
+ const start = Math.max(2, current - 1)
863
+ const end = Math.min(total - 1, current + 1)
864
+ if (start > 2) out.push('…')
865
+ for (let i = start; i <= end; i++) out.push(i)
866
+ if (end < total - 1) out.push('…')
867
+ out.push(total)
868
+ return out
869
+ }