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