@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,63 @@
1
+ import { cmsApi } from './api.js'
2
+
3
+ /**
4
+ * Mint a signed, time-boxed preview token for a single document. The token is
5
+ * bound to the (collection, documentId) pair server-side, so it can only
6
+ * unlock the exact draft it was issued for. Used to build public preview URLs
7
+ * (`/<prefix>/<slug>?preview=<token>`) that render a draft through the
8
+ * consumer's real front-end — see {@link buildPublicPreviewUrl}.
9
+ */
10
+ export async function createPreviewToken(
11
+ collection: string,
12
+ documentId: string,
13
+ ttlSeconds?: number,
14
+ ): Promise<{ token: string; expiresAt: string; slug?: string | null }> {
15
+ const res = await cmsApi<{ token: string; expiresAt: string; slug?: string | null }>(
16
+ '/preview/token',
17
+ {
18
+ method: 'POST',
19
+ body: JSON.stringify({ collection, documentId, ttlSeconds }),
20
+ },
21
+ )
22
+ if (res.error || !res.data) {
23
+ throw new Error(res.error ?? 'Failed to create preview token')
24
+ }
25
+ return res.data
26
+ }
27
+
28
+ export interface PublicPreviewUrlParts {
29
+ /** Absolute site origin, e.g. `https://example.com`. Trailing slash optional. */
30
+ siteUrl: string
31
+ /** Collection URL prefix (e.g. `blog`). Empty for top-level page collections. */
32
+ urlPrefix?: string | null
33
+ /** Document slug used in the public path. */
34
+ slug: string
35
+ /** Signed preview token from {@link createPreviewToken}. */
36
+ token: string
37
+ }
38
+
39
+ /**
40
+ * Build the public URL a draft preview opens at — the SAME path the
41
+ * `/resolve` endpoint matches: `<urlPrefix>/<slug>` (or `/<slug>` for
42
+ * top-level pages), with the preview token on the query string so the
43
+ * public route can drop the `PUBLISHED` filter for this one document.
44
+ */
45
+ export function buildPublicPreviewUrl({
46
+ siteUrl,
47
+ urlPrefix,
48
+ slug,
49
+ token,
50
+ }: PublicPreviewUrlParts): string {
51
+ const base = (siteUrl ?? '').replace(/\/+$/, '')
52
+ const prefix = (urlPrefix ?? '').replace(/^\/+|\/+$/g, '')
53
+ const cleanSlug = slug.replace(/^\/+|\/+$/g, '')
54
+ const path = prefix ? `/${prefix}/${cleanSlug}` : `/${cleanSlug}`
55
+ return `${base}${path}?preview=${encodeURIComponent(token)}`
56
+ }
57
+
58
+ /** Resolve the public site origin: explicit config first, else the current tab's origin. */
59
+ export function resolveSiteUrl(configSiteUrl?: string | null): string {
60
+ if (configSiteUrl && configSiteUrl.trim()) return configSiteUrl.trim()
61
+ if (typeof window !== 'undefined') return window.location.origin
62
+ return ''
63
+ }
@@ -44,6 +44,15 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
44
44
  window.history.pushState({ adminPath: normalizedPath }, '', fullUrl)
45
45
  }, [])
46
46
 
47
+ // Build an absolute (basePath-prefixed) admin URL without navigating. Used
48
+ // for links that must load a real document — e.g. opening a preview in a
49
+ // new browser tab, where `pushState` (which only mutates the current tab)
50
+ // is not an option.
51
+ const buildHref = useCallback((path: string) => {
52
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`
53
+ return normalizedPath === '/' ? baseRef.current : `${baseRef.current}${normalizedPath}`
54
+ }, [])
55
+
47
56
  const matchRoute = useCallback(
48
57
  (pattern: string): RouteParams | null => {
49
58
  const patternParts = pattern.split('/').filter(Boolean)
@@ -75,5 +84,5 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
75
84
  [currentPath],
76
85
  )
77
86
 
78
- return { currentPath, navigate, matchRoute }
87
+ return { currentPath, navigate, matchRoute, buildHref }
79
88
  }
@@ -358,12 +358,24 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
358
358
  // dashboard's content-creation toolbar. Order mirrors the natural
359
359
  // authoring flow (post → page → media → form → integration).
360
360
  const quickActions = useMemo(() => {
361
- const posts = collections.find((c) => c.slug === 'posts' || c.type === 'post')
362
- const postsSlug = posts?.slug ?? 'posts'
361
+ const postTypes = collections.filter((c) => c.type === 'post')
363
362
  const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
364
363
  const pagesSlug = pages?.slug ?? 'pages'
364
+ // "New Post": one Post Type → straight to its editor; several →
365
+ // the picker; none → the Post Type setup instructions (avoids a
366
+ // broken slug-based new URL).
367
+ const newPostPath =
368
+ postTypes.length === 1
369
+ ? `/${postTypes[0]!.slug}/new`
370
+ : postTypes.length > 1
371
+ ? '/posts/new'
372
+ : '/posts/types/new'
365
373
  const items: { label: string; icon: LucideIcon; onClick: () => void }[] = [
366
- { label: 'New Post', icon: Plus, onClick: () => nav(`/${postsSlug}/new`) },
374
+ {
375
+ label: 'New Post',
376
+ icon: Plus,
377
+ onClick: () => nav(newPostPath),
378
+ },
367
379
  { label: 'New Page', icon: Plus, onClick: () => nav(`/${pagesSlug}/new`) },
368
380
  { label: 'Upload Media', icon: Upload, onClick: () => nav('/media') },
369
381
  { label: 'New Form', icon: Plus, onClick: () => nav('/forms') },
@@ -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
+ }