@actuate-media/cms-admin 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +148 -2
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
  5. package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/posts-badges.test.js +68 -0
  7. package/dist/__tests__/components/posts-badges.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
  11. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
  12. package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
  13. package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/page-editor-service.test.js +137 -0
  15. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
  16. package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
  17. package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
  18. package/dist/__tests__/lib/pages-service.test.js +140 -0
  19. package/dist/__tests__/lib/pages-service.test.js.map +1 -0
  20. package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
  21. package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
  22. package/dist/__tests__/lib/post-editor-service.test.js +185 -0
  23. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
  24. package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
  25. package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
  26. package/dist/__tests__/lib/posts-service.test.js +244 -0
  27. package/dist/__tests__/lib/posts-service.test.js.map +1 -0
  28. package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
  29. package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/posts-list-view.test.js +98 -0
  31. package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
  32. package/dist/actuate-admin.css +1 -1
  33. package/dist/components/pages/tag-badges.d.ts +21 -0
  34. package/dist/components/pages/tag-badges.d.ts.map +1 -0
  35. package/dist/components/pages/tag-badges.js +56 -0
  36. package/dist/components/pages/tag-badges.js.map +1 -0
  37. package/dist/components/posts/badges.d.ts +25 -0
  38. package/dist/components/posts/badges.d.ts.map +1 -0
  39. package/dist/components/posts/badges.js +123 -0
  40. package/dist/components/posts/badges.js.map +1 -0
  41. package/dist/components/posts/icons.d.ts +12 -0
  42. package/dist/components/posts/icons.d.ts.map +1 -0
  43. package/dist/components/posts/icons.js +26 -0
  44. package/dist/components/posts/icons.js.map +1 -0
  45. package/dist/layout/Header.d.ts +14 -1
  46. package/dist/layout/Header.d.ts.map +1 -1
  47. package/dist/layout/Header.js +10 -3
  48. package/dist/layout/Header.js.map +1 -1
  49. package/dist/layout/Layout.d.ts.map +1 -1
  50. package/dist/layout/Layout.js +78 -2
  51. package/dist/layout/Layout.js.map +1 -1
  52. package/dist/layout/Sidebar.d.ts +16 -0
  53. package/dist/layout/Sidebar.d.ts.map +1 -1
  54. package/dist/layout/Sidebar.js +176 -49
  55. package/dist/layout/Sidebar.js.map +1 -1
  56. package/dist/lib/page-editor-service.d.ts +45 -0
  57. package/dist/lib/page-editor-service.d.ts.map +1 -0
  58. package/dist/lib/page-editor-service.js +153 -0
  59. package/dist/lib/page-editor-service.js.map +1 -0
  60. package/dist/lib/pages-service.d.ts +143 -0
  61. package/dist/lib/pages-service.d.ts.map +1 -0
  62. package/dist/lib/pages-service.js +444 -0
  63. package/dist/lib/pages-service.js.map +1 -0
  64. package/dist/lib/post-editor-service.d.ts +57 -0
  65. package/dist/lib/post-editor-service.d.ts.map +1 -0
  66. package/dist/lib/post-editor-service.js +234 -0
  67. package/dist/lib/post-editor-service.js.map +1 -0
  68. package/dist/lib/posts-service.d.ts +73 -0
  69. package/dist/lib/posts-service.d.ts.map +1 -0
  70. package/dist/lib/posts-service.js +245 -0
  71. package/dist/lib/posts-service.js.map +1 -0
  72. package/dist/lib/preview-link.d.ts +32 -0
  73. package/dist/lib/preview-link.d.ts.map +1 -0
  74. package/dist/lib/preview-link.js +40 -0
  75. package/dist/lib/preview-link.js.map +1 -0
  76. package/dist/router/index.d.ts +1 -0
  77. package/dist/router/index.d.ts.map +1 -1
  78. package/dist/router/index.js +9 -1
  79. package/dist/router/index.js.map +1 -1
  80. package/dist/views/Dashboard.d.ts.map +1 -1
  81. package/dist/views/Dashboard.js +147 -62
  82. package/dist/views/Dashboard.js.map +1 -1
  83. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  84. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  85. package/dist/views/Pages/PageTagManager.js +145 -0
  86. package/dist/views/Pages/PageTagManager.js.map +1 -0
  87. package/dist/views/Pages/PagesListView.d.ts +18 -0
  88. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  89. package/dist/views/Pages/PagesListView.js +287 -0
  90. package/dist/views/Pages/PagesListView.js.map +1 -0
  91. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  92. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  93. package/dist/views/Posts/NewPostPicker.js +59 -0
  94. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  95. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  96. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  97. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  98. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  99. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  100. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  101. package/dist/views/Posts/PostTypesView.js +52 -0
  102. package/dist/views/Posts/PostTypesView.js.map +1 -0
  103. package/dist/views/Posts/PostsListView.d.ts +25 -0
  104. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  105. package/dist/views/Posts/PostsListView.js +239 -0
  106. package/dist/views/Posts/PostsListView.js.map +1 -0
  107. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  108. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  109. package/dist/views/Posts/post-type-presets.js +72 -0
  110. package/dist/views/Posts/post-type-presets.js.map +1 -0
  111. package/dist/views/page-builder/Widgets.d.ts +13 -0
  112. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  113. package/dist/views/page-builder/Widgets.js +15 -0
  114. package/dist/views/page-builder/Widgets.js.map +1 -0
  115. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  116. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  117. package/dist/views/page-editor/AddSectionModal.js +22 -0
  118. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  119. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  120. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  121. package/dist/views/page-editor/EditorCanvas.js +64 -0
  122. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  123. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  124. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  125. package/dist/views/page-editor/EditorTopBar.js +42 -0
  126. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  127. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  128. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  129. package/dist/views/page-editor/PagePreview.js +37 -0
  130. package/dist/views/page-editor/PagePreview.js.map +1 -0
  131. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  132. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  133. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  134. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  135. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  136. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  137. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  138. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  139. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  140. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  141. package/dist/views/page-editor/SectionInspector.js +98 -0
  142. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  143. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  144. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  145. package/dist/views/page-editor/SectionsPanel.js +43 -0
  146. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  147. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  148. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  149. package/dist/views/page-editor/ValidationSummary.js +10 -0
  150. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  151. package/dist/views/page-editor/section-icons.d.ts +4 -0
  152. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  153. package/dist/views/page-editor/section-icons.js +20 -0
  154. package/dist/views/page-editor/section-icons.js.map +1 -0
  155. package/dist/views/page-editor/section-types.d.ts +11 -0
  156. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  157. package/dist/views/page-editor/section-types.js +10 -0
  158. package/dist/views/page-editor/section-types.js.map +1 -0
  159. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  160. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  161. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  162. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  163. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  164. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  165. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  166. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  167. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  168. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  169. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  170. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  171. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  172. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  173. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  174. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  175. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  176. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  177. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  178. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  179. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  180. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  181. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  182. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  183. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  184. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  185. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  186. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  187. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  188. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  189. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  190. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  191. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  192. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  193. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  194. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  195. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  196. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  197. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  198. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  199. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  200. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  201. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  202. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  203. package/dist/views/page-editor/sections/index.d.ts +14 -0
  204. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  205. package/dist/views/page-editor/sections/index.js +12 -0
  206. package/dist/views/page-editor/sections/index.js.map +1 -0
  207. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  208. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  209. package/dist/views/page-editor/sections/parts.js +60 -0
  210. package/dist/views/page-editor/sections/parts.js.map +1 -0
  211. package/dist/views/page-editor/viewports.d.ts +14 -0
  212. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  213. package/dist/views/page-editor/viewports.js +12 -0
  214. package/dist/views/page-editor/viewports.js.map +1 -0
  215. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  216. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  217. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  218. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  219. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  220. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  221. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  222. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  223. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  224. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  225. package/dist/views/post-editor/PostHeader.js +27 -0
  226. package/dist/views/post-editor/PostHeader.js.map +1 -0
  227. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  228. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  229. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  230. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  231. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  232. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  233. package/dist/views/post-editor/PostPreview.js +53 -0
  234. package/dist/views/post-editor/PostPreview.js.map +1 -0
  235. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  236. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  237. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  238. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  239. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  240. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  241. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  242. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  243. package/package.json +2 -2
  244. package/src/AdminRoot.tsx +213 -2
  245. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  246. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  247. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  248. package/src/__tests__/lib/pages-service.test.ts +157 -0
  249. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  250. package/src/__tests__/lib/posts-service.test.ts +279 -0
  251. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  252. package/src/components/pages/tag-badges.tsx +104 -0
  253. package/src/components/posts/badges.tsx +170 -0
  254. package/src/components/posts/icons.ts +40 -0
  255. package/src/layout/Header.tsx +90 -43
  256. package/src/layout/Layout.tsx +166 -1
  257. package/src/layout/Sidebar.tsx +287 -101
  258. package/src/lib/page-editor-service.ts +233 -0
  259. package/src/lib/pages-service.ts +599 -0
  260. package/src/lib/post-editor-service.ts +342 -0
  261. package/src/lib/posts-service.ts +366 -0
  262. package/src/lib/preview-link.ts +63 -0
  263. package/src/router/index.ts +10 -1
  264. package/src/views/Dashboard.tsx +270 -131
  265. package/src/views/Pages/PageTagManager.tsx +436 -0
  266. package/src/views/Pages/PagesListView.tsx +944 -0
  267. package/src/views/Posts/NewPostPicker.tsx +147 -0
  268. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  269. package/src/views/Posts/PostTypesView.tsx +194 -0
  270. package/src/views/Posts/PostsListView.tsx +869 -0
  271. package/src/views/Posts/post-type-presets.ts +105 -0
  272. package/src/views/page-builder/Widgets.tsx +44 -0
  273. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  274. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  275. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  276. package/src/views/page-editor/PagePreview.tsx +96 -0
  277. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  278. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  279. package/src/views/page-editor/SectionInspector.tsx +599 -0
  280. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  281. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  282. package/src/views/page-editor/section-icons.ts +33 -0
  283. package/src/views/page-editor/section-types.ts +27 -0
  284. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  285. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  286. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  287. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  288. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  289. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  290. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  291. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  292. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  293. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  294. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  295. package/src/views/page-editor/sections/index.ts +13 -0
  296. package/src/views/page-editor/sections/parts.tsx +113 -0
  297. package/src/views/page-editor/viewports.ts +25 -0
  298. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  299. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  300. package/src/views/post-editor/PostHeader.tsx +87 -0
  301. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  302. package/src/views/post-editor/PostPreview.tsx +114 -0
  303. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  304. package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
@@ -0,0 +1,436 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { ArrowLeft, GitMerge, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ createPageTag,
8
+ deletePageTagWithDetach,
9
+ fetchPages,
10
+ mergePageTags,
11
+ updatePageTag,
12
+ type PageTag,
13
+ } from '../../lib/pages-service.js'
14
+ import { PageTagPill, tagDotColor } from '../../components/pages/tag-badges.js'
15
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
16
+
17
+ const TAG_COLORS = ['purple', 'green', 'orange', 'red', 'blue', 'pink', 'gray'] as const
18
+
19
+ export interface PageTagManagerProps {
20
+ onNavigate: (path: string) => void
21
+ /** When set, the create form opens pre-expanded (route /pages/tags/new). */
22
+ startCreating?: boolean
23
+ }
24
+
25
+ /**
26
+ * /pages/tags — create, rename, recolor, merge, and delete page tags.
27
+ *
28
+ * Tags are documents in the hidden `page-tags` collection; pages
29
+ * reference them by slug in `data.tags`. Deleting a tag detaches it
30
+ * from every page first so no dangling slugs remain.
31
+ */
32
+ export function PageTagManager({ onNavigate, startCreating = false }: PageTagManagerProps) {
33
+ const [tags, setTags] = useState<PageTag[]>([])
34
+ const [loading, setLoading] = useState(true)
35
+ const [error, setError] = useState<string | null>(null)
36
+ const [refreshToken, setRefreshToken] = useState(0)
37
+
38
+ const [creating, setCreating] = useState(startCreating)
39
+ const [pendingDelete, setPendingDelete] = useState<PageTag | null>(null)
40
+
41
+ const existingSlugs = useMemo(() => new Set(tags.map((t) => t.slug)), [tags])
42
+
43
+ const load = useCallback(async () => {
44
+ setLoading(true)
45
+ setError(null)
46
+ try {
47
+ // fetchPages returns tags with accurate page counts.
48
+ const res = await fetchPages({ pageSize: 1 })
49
+ setTags(res.tags)
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : 'Failed to load tags')
52
+ } finally {
53
+ setLoading(false)
54
+ }
55
+ }, [])
56
+
57
+ useEffect(() => {
58
+ void load()
59
+ }, [load, refreshToken])
60
+
61
+ const refresh = () => setRefreshToken((t) => t + 1)
62
+
63
+ const handleDelete = async (tag: PageTag) => {
64
+ const promise = deletePageTagWithDetach(tag)
65
+ toast.promise(promise, {
66
+ loading: `Deleting "${tag.name}"…`,
67
+ success: () => `Deleted "${tag.name}"`,
68
+ error: 'Failed to delete tag',
69
+ })
70
+ await promise
71
+ setPendingDelete(null)
72
+ refresh()
73
+ }
74
+
75
+ return (
76
+ <div className="p-4 pr-8 sm:p-6">
77
+ <button
78
+ type="button"
79
+ onClick={() => onNavigate('/pages')}
80
+ className="text-muted-foreground hover:text-foreground mb-4 inline-flex items-center gap-1.5 text-sm"
81
+ >
82
+ <ArrowLeft className="h-4 w-4" aria-hidden />
83
+ Back to Pages
84
+ </button>
85
+
86
+ <div className="mb-5 flex items-center justify-between">
87
+ <div>
88
+ <h1 className="text-foreground mb-1 text-xl font-medium tracking-tight sm:text-2xl">
89
+ Page Tags
90
+ </h1>
91
+ <p className="text-muted-foreground text-sm">
92
+ Organize pages with reusable, color-coded labels.
93
+ </p>
94
+ </div>
95
+ {!creating && (
96
+ <button
97
+ type="button"
98
+ onClick={() => setCreating(true)}
99
+ 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"
100
+ >
101
+ <Plus className="h-3.5 w-3.5" aria-hidden />
102
+ New tag
103
+ </button>
104
+ )}
105
+ </div>
106
+
107
+ {creating && (
108
+ <CreateTagForm
109
+ existingSlugs={existingSlugs}
110
+ onCancel={() => setCreating(false)}
111
+ onCreated={() => {
112
+ setCreating(false)
113
+ refresh()
114
+ }}
115
+ />
116
+ )}
117
+
118
+ {loading ? (
119
+ <div className="flex h-48 items-center justify-center" role="status" aria-live="polite">
120
+ <Loader2 className="text-primary h-6 w-6 animate-spin" aria-hidden />
121
+ <span className="sr-only">Loading tags</span>
122
+ </div>
123
+ ) : error ? (
124
+ <div className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
125
+ <span className="text-foreground flex-1 text-sm">{error}</span>
126
+ <button
127
+ type="button"
128
+ onClick={refresh}
129
+ className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm"
130
+ >
131
+ Retry
132
+ </button>
133
+ </div>
134
+ ) : tags.length === 0 ? (
135
+ <div className="border-border bg-card rounded-lg border p-8 text-center">
136
+ <h2 className="text-foreground mb-1 text-lg font-medium">No tags yet</h2>
137
+ <p className="text-muted-foreground text-sm">Create a tag to start organizing pages.</p>
138
+ </div>
139
+ ) : (
140
+ <div className="bg-card border-border divide-border divide-y overflow-hidden rounded-xl border shadow-sm">
141
+ {tags.map((tag) => (
142
+ <TagRow
143
+ key={tag.docId}
144
+ tag={tag}
145
+ allTags={tags}
146
+ onChanged={refresh}
147
+ onRequestDelete={() => setPendingDelete(tag)}
148
+ />
149
+ ))}
150
+ </div>
151
+ )}
152
+
153
+ <ConfirmDialog
154
+ open={pendingDelete !== null}
155
+ title={`Delete "${pendingDelete?.name ?? ''}"?`}
156
+ description={
157
+ pendingDelete && pendingDelete.pageCount > 0
158
+ ? `This tag is used on ${pendingDelete.pageCount} page${
159
+ pendingDelete.pageCount === 1 ? '' : 's'
160
+ }. It will be removed from those pages. This cannot be undone.`
161
+ : 'This tag has no pages. Deleting it cannot be undone.'
162
+ }
163
+ confirmLabel="Delete tag"
164
+ destructive
165
+ onClose={() => setPendingDelete(null)}
166
+ onConfirm={() => {
167
+ if (pendingDelete) void handleDelete(pendingDelete)
168
+ }}
169
+ />
170
+ </div>
171
+ )
172
+ }
173
+
174
+ // ─── Create form ─────────────────────────────────────────────────────
175
+
176
+ function CreateTagForm({
177
+ existingSlugs,
178
+ onCancel,
179
+ onCreated,
180
+ }: {
181
+ existingSlugs: Set<string>
182
+ onCancel: () => void
183
+ onCreated: () => void
184
+ }) {
185
+ const [name, setName] = useState('')
186
+ const [color, setColor] = useState<string>('purple')
187
+ const [submitting, setSubmitting] = useState(false)
188
+
189
+ const slug = name
190
+ .toLowerCase()
191
+ .trim()
192
+ .replace(/[^a-z0-9]+/g, '-')
193
+ .replace(/^-+|-+$/g, '')
194
+ const duplicate = slug.length > 0 && existingSlugs.has(slug)
195
+ const canSubmit = slug.length > 0 && !duplicate && !submitting
196
+
197
+ const submit = async () => {
198
+ if (!canSubmit) return
199
+ setSubmitting(true)
200
+ const res = await createPageTag({ name: name.trim(), color })
201
+ setSubmitting(false)
202
+ if (res.error) {
203
+ toast.error(res.error)
204
+ return
205
+ }
206
+ toast.success(`Created "${name.trim()}"`)
207
+ onCreated()
208
+ }
209
+
210
+ return (
211
+ <div className="border-border bg-card mb-5 rounded-xl border p-4 shadow-sm">
212
+ <div className="flex flex-wrap items-end gap-3">
213
+ <label className="flex-1">
214
+ <span className="text-foreground mb-1 block text-sm font-medium">Tag name</span>
215
+ <input
216
+ type="text"
217
+ value={name}
218
+ autoFocus
219
+ onChange={(e) => setName(e.target.value)}
220
+ onKeyDown={(e) => {
221
+ if (e.key === 'Enter') void submit()
222
+ }}
223
+ placeholder="e.g. Marketing"
224
+ className="border-border bg-input-background text-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
225
+ />
226
+ </label>
227
+ <ColorPicker value={color} onChange={setColor} />
228
+ <button
229
+ type="button"
230
+ onClick={() => void submit()}
231
+ disabled={!canSubmit}
232
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium shadow-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
233
+ >
234
+ {submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
235
+ Create
236
+ </button>
237
+ <button
238
+ type="button"
239
+ onClick={onCancel}
240
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 px-2 py-2 text-sm"
241
+ >
242
+ <X className="h-4 w-4" aria-hidden />
243
+ Cancel
244
+ </button>
245
+ </div>
246
+ {duplicate && (
247
+ <p className="text-destructive mt-2 text-xs">A tag with that name already exists.</p>
248
+ )}
249
+ </div>
250
+ )
251
+ }
252
+
253
+ function ColorPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
254
+ return (
255
+ <div>
256
+ <span className="text-foreground mb-1 block text-sm font-medium">Color</span>
257
+ <div className="flex items-center gap-1.5" role="radiogroup" aria-label="Tag color">
258
+ {TAG_COLORS.map((c) => (
259
+ <button
260
+ key={c}
261
+ type="button"
262
+ role="radio"
263
+ aria-checked={value === c}
264
+ aria-label={c}
265
+ onClick={() => onChange(c)}
266
+ className={`h-7 w-7 rounded-full ${tagDotColor(c)} ring-offset-background transition-shadow ${
267
+ value === c ? 'ring-ring ring-2 ring-offset-2' : ''
268
+ }`}
269
+ />
270
+ ))}
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ // ─── Tag row ─────────────────────────────────────────────────────────
277
+
278
+ function TagRow({
279
+ tag,
280
+ allTags,
281
+ onChanged,
282
+ onRequestDelete,
283
+ }: {
284
+ tag: PageTag
285
+ allTags: PageTag[]
286
+ onChanged: () => void
287
+ onRequestDelete: () => void
288
+ }) {
289
+ const [editing, setEditing] = useState(false)
290
+ const [name, setName] = useState(tag.name)
291
+ const [color, setColor] = useState(tag.color ?? 'gray')
292
+ const [merging, setMerging] = useState(false)
293
+
294
+ const save = async () => {
295
+ const res = await updatePageTag(tag.docId, { name: name.trim(), color })
296
+ if (res.error) {
297
+ toast.error(res.error)
298
+ return
299
+ }
300
+ toast.success('Tag updated')
301
+ setEditing(false)
302
+ onChanged()
303
+ }
304
+
305
+ const merge = async (targetSlug: string) => {
306
+ const target = allTags.find((t) => t.slug === targetSlug)
307
+ if (!target) return
308
+ const promise = mergePageTags(tag, target)
309
+ toast.promise(promise, {
310
+ loading: `Merging "${tag.name}" into "${target.name}"…`,
311
+ success: () => `Merged into "${target.name}"`,
312
+ error: 'Merge failed',
313
+ })
314
+ await promise
315
+ setMerging(false)
316
+ onChanged()
317
+ }
318
+
319
+ if (editing) {
320
+ return (
321
+ <div className="flex flex-wrap items-end gap-3 p-4">
322
+ <label className="flex-1">
323
+ <span className="sr-only">Tag name</span>
324
+ <input
325
+ type="text"
326
+ value={name}
327
+ onChange={(e) => setName(e.target.value)}
328
+ className="border-border bg-input-background text-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
329
+ />
330
+ </label>
331
+ <ColorPicker value={color} onChange={setColor} />
332
+ <button
333
+ type="button"
334
+ onClick={() => void save()}
335
+ className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-3.5 py-2 text-sm font-medium"
336
+ >
337
+ Save
338
+ </button>
339
+ <button
340
+ type="button"
341
+ onClick={() => {
342
+ setEditing(false)
343
+ setName(tag.name)
344
+ setColor(tag.color ?? 'gray')
345
+ }}
346
+ className="text-muted-foreground hover:text-foreground px-2 py-2 text-sm"
347
+ >
348
+ Cancel
349
+ </button>
350
+ </div>
351
+ )
352
+ }
353
+
354
+ return (
355
+ <div className="flex items-center gap-3 p-4">
356
+ <div className="flex min-w-0 flex-1 items-center gap-3">
357
+ <PageTagPill name={tag.name} color={tag.color} />
358
+ <span className="text-muted-foreground text-sm tabular-nums">
359
+ {tag.pageCount} page{tag.pageCount === 1 ? '' : 's'}
360
+ </span>
361
+ </div>
362
+
363
+ {merging ? (
364
+ <div className="flex items-center gap-2">
365
+ <label className="text-foreground inline-flex items-center gap-1.5 text-sm">
366
+ <span className="text-muted-foreground">Merge into:</span>
367
+ <select
368
+ defaultValue=""
369
+ onChange={(e) => {
370
+ if (e.target.value) void merge(e.target.value)
371
+ }}
372
+ className="border-border bg-card text-foreground rounded-lg border px-2.5 py-1.5 text-sm"
373
+ >
374
+ <option value="" disabled>
375
+ Choose tag…
376
+ </option>
377
+ {allTags
378
+ .filter((t) => t.slug !== tag.slug)
379
+ .map((t) => (
380
+ <option key={t.slug} value={t.slug}>
381
+ {t.name}
382
+ </option>
383
+ ))}
384
+ </select>
385
+ </label>
386
+ <button
387
+ type="button"
388
+ onClick={() => setMerging(false)}
389
+ className="text-muted-foreground hover:text-foreground px-2 py-1 text-sm"
390
+ >
391
+ Cancel
392
+ </button>
393
+ </div>
394
+ ) : (
395
+ <div className="flex items-center gap-1">
396
+ <IconAction label={`Edit ${tag.name}`} onClick={() => setEditing(true)}>
397
+ <Pencil className="h-4 w-4" aria-hidden />
398
+ </IconAction>
399
+ {allTags.length > 1 && (
400
+ <IconAction label={`Merge ${tag.name}`} onClick={() => setMerging(true)}>
401
+ <GitMerge className="h-4 w-4" aria-hidden />
402
+ </IconAction>
403
+ )}
404
+ <IconAction label={`Delete ${tag.name}`} onClick={onRequestDelete} destructive>
405
+ <Trash2 className="h-4 w-4" aria-hidden />
406
+ </IconAction>
407
+ </div>
408
+ )}
409
+ </div>
410
+ )
411
+ }
412
+
413
+ function IconAction({
414
+ label,
415
+ onClick,
416
+ destructive,
417
+ children,
418
+ }: {
419
+ label: string
420
+ onClick: () => void
421
+ destructive?: boolean
422
+ children: React.ReactNode
423
+ }) {
424
+ return (
425
+ <button
426
+ type="button"
427
+ onClick={onClick}
428
+ aria-label={label}
429
+ className={`hover:bg-accent inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${
430
+ destructive ? 'text-destructive hover:bg-destructive/10' : 'text-muted-foreground'
431
+ }`}
432
+ >
433
+ {children}
434
+ </button>
435
+ )
436
+ }