@actuate-media/cms-admin 0.66.0 → 0.68.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 (244) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +36 -9
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  10. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  11. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  12. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  13. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  14. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  16. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  17. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  18. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  19. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  20. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  21. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  22. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  23. package/dist/__tests__/views/dashboard.test.js +7 -3
  24. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  25. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  26. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  27. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  28. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  29. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  30. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  31. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  34. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  35. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  36. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  38. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  39. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  40. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  41. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  42. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  43. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  44. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  45. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  46. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  47. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  48. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  49. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  50. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  51. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  52. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  53. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  54. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  55. package/dist/actuate-admin.css +1 -1
  56. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  57. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  58. package/dist/components/collections/EntryDetailPane.js +92 -0
  59. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  60. package/dist/components/forms/FieldsPanel.js +1 -1
  61. package/dist/components/forms/FieldsPanel.js.map +1 -1
  62. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  63. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  64. package/dist/components/forms/FormSchemaPane.js +95 -0
  65. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  66. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  67. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  68. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  69. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  70. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  71. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  72. package/dist/components/seo/SeoEditorPane.js +175 -0
  73. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  74. package/dist/components/ui/Badge.d.ts +1 -1
  75. package/dist/components/ui/Button.d.ts +1 -1
  76. package/dist/components/ui/InspectorPane.d.ts +51 -0
  77. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  78. package/dist/components/ui/InspectorPane.js +42 -0
  79. package/dist/components/ui/InspectorPane.js.map +1 -0
  80. package/dist/components/ui/StatusBadge.d.ts +1 -1
  81. package/dist/components/ui/index.d.ts +2 -0
  82. package/dist/components/ui/index.d.ts.map +1 -1
  83. package/dist/components/ui/index.js +1 -0
  84. package/dist/components/ui/index.js.map +1 -1
  85. package/dist/hooks/useIdleLogout.d.ts +18 -0
  86. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  87. package/dist/hooks/useIdleLogout.js +66 -0
  88. package/dist/hooks/useIdleLogout.js.map +1 -0
  89. package/dist/lib/api.d.ts +1 -0
  90. package/dist/lib/api.d.ts.map +1 -1
  91. package/dist/lib/api.js +7 -1
  92. package/dist/lib/api.js.map +1 -1
  93. package/dist/lib/page-editor-service.d.ts +11 -2
  94. package/dist/lib/page-editor-service.d.ts.map +1 -1
  95. package/dist/lib/page-editor-service.js +8 -2
  96. package/dist/lib/page-editor-service.js.map +1 -1
  97. package/dist/lib/pages-tree.d.ts +44 -0
  98. package/dist/lib/pages-tree.d.ts.map +1 -0
  99. package/dist/lib/pages-tree.js +106 -0
  100. package/dist/lib/pages-tree.js.map +1 -0
  101. package/dist/lib/post-types-service.d.ts +44 -0
  102. package/dist/lib/post-types-service.d.ts.map +1 -0
  103. package/dist/lib/post-types-service.js +83 -0
  104. package/dist/lib/post-types-service.js.map +1 -0
  105. package/dist/views/CollectionList.d.ts.map +1 -1
  106. package/dist/views/CollectionList.js +23 -4
  107. package/dist/views/CollectionList.js.map +1 -1
  108. package/dist/views/Dashboard.d.ts.map +1 -1
  109. package/dist/views/Dashboard.js +31 -2
  110. package/dist/views/Dashboard.js.map +1 -1
  111. package/dist/views/Forms.d.ts.map +1 -1
  112. package/dist/views/Forms.js +25 -21
  113. package/dist/views/Forms.js.map +1 -1
  114. package/dist/views/MediaBrowser.d.ts.map +1 -1
  115. package/dist/views/MediaBrowser.js +28 -2
  116. package/dist/views/MediaBrowser.js.map +1 -1
  117. package/dist/views/MediaSeoAudit.d.ts +25 -0
  118. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  119. package/dist/views/MediaSeoAudit.js +97 -0
  120. package/dist/views/MediaSeoAudit.js.map +1 -0
  121. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  122. package/dist/views/Pages/PagesListView.js +90 -10
  123. package/dist/views/Pages/PagesListView.js.map +1 -1
  124. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  125. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  126. package/dist/views/Pages/PagesTreeTable.js +28 -0
  127. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  128. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  129. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  130. package/dist/views/Posts/PostTypeEditor.js +159 -0
  131. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  132. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  133. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  134. package/dist/views/Posts/PostTypesView.js +1 -1
  135. package/dist/views/Posts/PostTypesView.js.map +1 -1
  136. package/dist/views/ScriptTags.d.ts +5 -1
  137. package/dist/views/ScriptTags.d.ts.map +1 -1
  138. package/dist/views/ScriptTags.js +32 -7
  139. package/dist/views/ScriptTags.js.map +1 -1
  140. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  141. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  142. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  143. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  144. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  145. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  146. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  147. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  148. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  149. package/dist/views/page-builder/PageBuilder.js +51 -2
  150. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  151. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  152. package/dist/views/page-builder/PageTemplates.js +113 -6
  153. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  154. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  155. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  156. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  157. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  158. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  159. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  160. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  161. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  162. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  163. package/dist/views/seo/ContentTab.js +22 -6
  164. package/dist/views/seo/ContentTab.js.map +1 -1
  165. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  166. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  167. package/dist/views/settings/AccessProtectionCard.js +13 -10
  168. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  169. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  170. package/dist/views/settings/SecurityTab.js +3 -3
  171. package/dist/views/settings/SecurityTab.js.map +1 -1
  172. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  173. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  174. package/dist/views/settings/SessionPolicyCard.js +12 -5
  175. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  176. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  177. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  178. package/dist/views/settings/useSecuritySettings.js +47 -4
  179. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  180. package/package.json +2 -2
  181. package/src/AdminRoot.tsx +43 -10
  182. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  183. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  184. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  185. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  186. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  187. package/src/__tests__/views/dashboard.test.tsx +7 -3
  188. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  189. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  190. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  191. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  192. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  193. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  194. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  195. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  196. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  197. package/src/components/collections/EntryDetailPane.tsx +182 -0
  198. package/src/components/forms/FieldsPanel.tsx +3 -1
  199. package/src/components/forms/FormSchemaPane.tsx +192 -0
  200. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  201. package/src/components/seo/SeoEditorPane.tsx +533 -0
  202. package/src/components/ui/InspectorPane.tsx +129 -0
  203. package/src/components/ui/index.ts +10 -0
  204. package/src/hooks/useIdleLogout.ts +77 -0
  205. package/src/lib/api.ts +8 -2
  206. package/src/lib/page-editor-service.ts +16 -2
  207. package/src/lib/pages-tree.ts +145 -0
  208. package/src/lib/post-types-service.ts +129 -0
  209. package/src/views/CollectionList.tsx +158 -110
  210. package/src/views/Dashboard.tsx +238 -196
  211. package/src/views/Forms.tsx +154 -126
  212. package/src/views/MediaBrowser.tsx +641 -511
  213. package/src/views/MediaSeoAudit.tsx +320 -0
  214. package/src/views/Pages/PagesListView.tsx +352 -183
  215. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  216. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  217. package/src/views/Posts/PostTypesView.tsx +5 -4
  218. package/src/views/ScriptTags.tsx +204 -129
  219. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  220. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  221. package/src/views/page-builder/PageBuilder.tsx +69 -2
  222. package/src/views/page-builder/PageTemplates.tsx +340 -57
  223. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  224. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  225. package/src/views/seo/ContentTab.tsx +113 -80
  226. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  227. package/src/views/settings/SecurityTab.tsx +23 -2
  228. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  229. package/src/views/settings/useSecuritySettings.ts +78 -22
  230. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  231. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  232. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  233. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  234. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  235. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  236. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  237. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  238. package/dist/views/ScriptTagEditor.d.ts +0 -6
  239. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  240. package/dist/views/ScriptTagEditor.js +0 -111
  241. package/dist/views/ScriptTagEditor.js.map +0 -1
  242. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  243. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
  244. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ /** How often the idle checker wakes up to compare against the deadline. */
6
+ const CHECK_INTERVAL_MS = 30_000
7
+
8
+ /**
9
+ * Signs the user out after `timeoutMs` of no user activity, when enabled by
10
+ * the Settings → Security "Idle auto-logout" policy.
11
+ *
12
+ * Deliberately lightweight: activity events only stamp a ref (no state, no
13
+ * re-renders), and a single coarse interval compares the last-activity stamp
14
+ * against the deadline. Returning to a hidden tab re-checks immediately via
15
+ * `visibilitychange`, so a tab left overnight logs out as soon as it's seen
16
+ * (or at the next interval tick) rather than staying signed in.
17
+ *
18
+ * All listeners and the interval are torn down on unmount or when disabled.
19
+ */
20
+ export function useIdleLogout(options: {
21
+ enabled: boolean
22
+ timeoutMs: number
23
+ onIdle: () => void
24
+ }): void {
25
+ const { enabled, timeoutMs } = options
26
+ // Keep the latest callback without re-subscribing listeners on every render.
27
+ const onIdleRef = useRef(options.onIdle)
28
+ onIdleRef.current = options.onIdle
29
+
30
+ useEffect(() => {
31
+ if (!enabled || timeoutMs <= 0 || typeof window === 'undefined') return
32
+
33
+ let lastActivity = Date.now()
34
+ let fired = false
35
+
36
+ const markActivity = () => {
37
+ lastActivity = Date.now()
38
+ }
39
+
40
+ const checkIdle = () => {
41
+ if (fired) return
42
+ if (Date.now() - lastActivity >= timeoutMs) {
43
+ fired = true
44
+ onIdleRef.current()
45
+ }
46
+ }
47
+
48
+ // Passive listeners — activity stamping must never block scrolling/typing.
49
+ const activityEvents = [
50
+ 'pointermove',
51
+ 'pointerdown',
52
+ 'keydown',
53
+ 'scroll',
54
+ 'touchstart',
55
+ ] as const
56
+ for (const event of activityEvents) {
57
+ window.addEventListener(event, markActivity, { passive: true })
58
+ }
59
+
60
+ // A tab becoming visible again is NOT user activity — check whether the
61
+ // deadline passed while it was hidden and log out immediately if so.
62
+ const onVisibility = () => {
63
+ if (document.visibilityState === 'visible') checkIdle()
64
+ }
65
+ document.addEventListener('visibilitychange', onVisibility)
66
+
67
+ const interval = window.setInterval(checkIdle, Math.min(CHECK_INTERVAL_MS, timeoutMs))
68
+
69
+ return () => {
70
+ for (const event of activityEvents) {
71
+ window.removeEventListener(event, markActivity)
72
+ }
73
+ document.removeEventListener('visibilitychange', onVisibility)
74
+ window.clearInterval(interval)
75
+ }
76
+ }, [enabled, timeoutMs])
77
+ }
package/src/lib/api.ts CHANGED
@@ -65,7 +65,7 @@ function appendLocaleParam(endpoint: string): string {
65
65
  export async function cmsApi<T = unknown>(
66
66
  endpoint: string,
67
67
  options: RequestInit = {},
68
- ): Promise<{ data?: T; error?: string; status: number }> {
68
+ ): Promise<{ data?: T; error?: string; code?: string; status: number }> {
69
69
  const url = `${basePath}${appendLocaleParam(endpoint)}`
70
70
  const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
71
71
 
@@ -96,7 +96,13 @@ export async function cmsApi<T = unknown>(
96
96
  const json = await res.json().catch(() => ({}))
97
97
 
98
98
  if (!res.ok) {
99
- return { error: json.error || `Request failed (${res.status})`, status: res.status }
99
+ return {
100
+ error: json.error || `Request failed (${res.status})`,
101
+ // Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK) set by
102
+ // guard responses so callers can branch without parsing the message.
103
+ code: typeof json.code === 'string' ? json.code : undefined,
104
+ status: res.status,
105
+ }
100
106
  }
101
107
 
102
108
  return { data: json.data ?? json, status: res.status }
@@ -64,6 +64,12 @@ export interface EditorPage {
64
64
  slug: string
65
65
  path: string
66
66
  status: PageStatus
67
+ /**
68
+ * Parent page for the tree hierarchy (`data.parentPageId`), or null for
69
+ * a top-level page. Preselected when arriving via "Add sub-page"
70
+ * (`/pages/new?parent=<id>`); editable in Page settings.
71
+ */
72
+ parentPageId: string | null
67
73
  sections: PageSection[]
68
74
  seoTitle: string
69
75
  seoDescription: string
@@ -192,6 +198,8 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
192
198
  slug,
193
199
  path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
194
200
  status: doc.status ?? 'DRAFT',
201
+ parentPageId:
202
+ typeof data.parentPageId === 'string' && data.parentPageId ? data.parentPageId : null,
195
203
  // Preserve unknown (externally-managed) sections so the editor can show
196
204
  // them read-only and round-trip them on save instead of dropping them.
197
205
  sections: coerceSectionsCore(data.sections, { onUnknown: 'preserve' }),
@@ -215,6 +223,7 @@ interface PageWritePayload {
215
223
  title: string
216
224
  slug: string
217
225
  path: string
226
+ parentPageId: string | null
218
227
  sections: PageSection[]
219
228
  metaTitle: string
220
229
  metaDescription: string
@@ -230,6 +239,7 @@ function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string
230
239
  title: page.title,
231
240
  slug: page.slug,
232
241
  path: page.path,
242
+ parentPageId: page.parentPageId,
233
243
  // Unmanaged sections are part of `page.sections`, so they round-trip on
234
244
  // every save (no data loss). Strip the derived `unmanaged` marker — it is
235
245
  // recomputed from the registry on read, so persisting it would only stale.
@@ -275,14 +285,18 @@ export async function publishPage(page: EditorPage): Promise<EditorPage> {
275
285
  return rowToEditorPage(res.data)
276
286
  }
277
287
 
278
- /** A blank page scaffold for `/pages/new`. */
279
- export function emptyPage(): EditorPage {
288
+ /**
289
+ * A blank page scaffold for `/pages/new`. `parentPageId` is preset when
290
+ * the flow was entered via "Add sub-page" in the Pages tree view.
291
+ */
292
+ export function emptyPage(parentPageId: string | null = null): EditorPage {
280
293
  return {
281
294
  id: null,
282
295
  title: '',
283
296
  slug: '',
284
297
  path: '',
285
298
  status: 'DRAFT',
299
+ parentPageId,
286
300
  sections: [],
287
301
  seoTitle: '',
288
302
  seoDescription: '',
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Page hierarchy resolution for the Pages tree view.
3
+ *
4
+ * Parent resolution strategy (in priority order):
5
+ * 1. `parentPageId` — explicit link, used when it points at a page that
6
+ * exists in the set (and isn't the page itself).
7
+ * 2. Path-prefix derivation — for rows without a usable `parentPageId`,
8
+ * the longest existing path prefix wins: "/about/team" becomes a child
9
+ * of the page whose path is "/about". Missing intermediate levels are
10
+ * skipped ("/a/b/c" with no "/a/b" attaches to "/a"). The root path
11
+ * "/" is never treated as a prefix parent, so top-level pages stay
12
+ * siblings of the homepage.
13
+ *
14
+ * Safety guarantees:
15
+ * - Orphans (parent id not in the set) become roots.
16
+ * - Cycles (a → b → a) are broken deterministically: walking up from each
17
+ * page in input order, the edge that closes a cycle is severed and that
18
+ * page becomes a root. Every page appears in the tree exactly once.
19
+ * - Sibling ordering is stable: title (locale-aware), then path.
20
+ *
21
+ * Pure functions — no network, no React — so the view and the unit tests
22
+ * share one implementation.
23
+ */
24
+ import type { Page } from './pages-service.js'
25
+
26
+ export interface PageTreeNode {
27
+ page: Page
28
+ children: PageTreeNode[]
29
+ }
30
+
31
+ /** One visible row of the flattened tree table. */
32
+ export interface PageTreeRow {
33
+ page: Page
34
+ depth: number
35
+ hasChildren: boolean
36
+ }
37
+
38
+ function normalizePath(path: string): string {
39
+ let p = path.trim()
40
+ if (!p.startsWith('/')) p = `/${p}`
41
+ p = p.replace(/\/{2,}/g, '/')
42
+ if (p.length > 1) p = p.replace(/\/+$/, '')
43
+ return p
44
+ }
45
+
46
+ /**
47
+ * Longest existing path-prefix parent, or null. Only prefixes with at
48
+ * least one segment count — "/" (the homepage) is never a derived parent.
49
+ */
50
+ function derivePathParent(page: Page, idByPath: Map<string, string>): string | null {
51
+ const segments = normalizePath(page.path).split('/').filter(Boolean)
52
+ for (let i = segments.length - 1; i >= 1; i--) {
53
+ const prefix = `/${segments.slice(0, i).join('/')}`
54
+ const candidate = idByPath.get(prefix)
55
+ if (candidate !== undefined && candidate !== page.id) return candidate
56
+ }
57
+ return null
58
+ }
59
+
60
+ function compareSiblings(a: PageTreeNode, b: PageTreeNode): number {
61
+ return (
62
+ a.page.title.localeCompare(b.page.title) ||
63
+ a.page.path.localeCompare(b.page.path) ||
64
+ a.page.id.localeCompare(b.page.id)
65
+ )
66
+ }
67
+
68
+ /** Build the parent/child hierarchy for the tree view. Returns root nodes. */
69
+ export function buildPageTree(pages: Page[]): PageTreeNode[] {
70
+ const byId = new Map<string, Page>()
71
+ for (const p of pages) byId.set(p.id, p)
72
+
73
+ // First occurrence wins on duplicate paths, keeping resolution stable
74
+ // with respect to input order.
75
+ const idByPath = new Map<string, string>()
76
+ for (const p of pages) {
77
+ const norm = normalizePath(p.path)
78
+ if (!idByPath.has(norm)) idByPath.set(norm, p.id)
79
+ }
80
+
81
+ const parentOf = new Map<string, string | null>()
82
+ for (const p of pages) {
83
+ const explicit =
84
+ p.parentPageId && p.parentPageId !== p.id && byId.has(p.parentPageId) ? p.parentPageId : null
85
+ parentOf.set(p.id, explicit ?? derivePathParent(p, idByPath))
86
+ }
87
+
88
+ // Cycle guard: walk each page's ancestor chain; the edge that revisits a
89
+ // seen node is severed, rooting that page. Deterministic in input order.
90
+ for (const p of pages) {
91
+ const seen = new Set<string>([p.id])
92
+ let cur = p.id
93
+ for (;;) {
94
+ const parent = parentOf.get(cur) ?? null
95
+ if (parent === null) break
96
+ if (seen.has(parent)) {
97
+ parentOf.set(cur, null)
98
+ break
99
+ }
100
+ seen.add(parent)
101
+ cur = parent
102
+ }
103
+ }
104
+
105
+ const nodeById = new Map<string, PageTreeNode>()
106
+ for (const p of pages) nodeById.set(p.id, { page: p, children: [] })
107
+
108
+ const roots: PageTreeNode[] = []
109
+ for (const p of pages) {
110
+ const node = nodeById.get(p.id)!
111
+ const parentId = parentOf.get(p.id) ?? null
112
+ const parentNode = parentId !== null ? nodeById.get(parentId) : undefined
113
+ if (parentNode) parentNode.children.push(node)
114
+ else roots.push(node)
115
+ }
116
+
117
+ const sortRecursive = (nodes: PageTreeNode[]) => {
118
+ nodes.sort(compareSiblings)
119
+ for (const n of nodes) sortRecursive(n.children)
120
+ }
121
+ sortRecursive(roots)
122
+ return roots
123
+ }
124
+
125
+ /**
126
+ * Flatten the tree into visible rows: children render only when their
127
+ * parent's id is in `expanded`.
128
+ */
129
+ export function flattenPageTree(nodes: PageTreeNode[], expanded: Set<string>): PageTreeRow[] {
130
+ const rows: PageTreeRow[] = []
131
+ const walk = (list: PageTreeNode[], depth: number) => {
132
+ for (const node of list) {
133
+ const hasChildren = node.children.length > 0
134
+ rows.push({ page: node.page, depth, hasChildren })
135
+ if (hasChildren && expanded.has(node.page.id)) walk(node.children, depth + 1)
136
+ }
137
+ }
138
+ walk(nodes, 0)
139
+ return rows
140
+ }
141
+
142
+ /** Default expand state: every root node that has children starts open. */
143
+ export function defaultExpandedIds(roots: PageTreeNode[]): Set<string> {
144
+ return new Set(roots.filter((n) => n.children.length > 0).map((n) => n.page.id))
145
+ }
@@ -0,0 +1,129 @@
1
+ import { cmsApi } from './api.js'
2
+
3
+ /**
4
+ * Detail metadata for one post type (collection), sourced from the public
5
+ * `GET /collections` introspection endpoint. Read-only by design: collection
6
+ * schemas are file-defined in `actuate.config.ts`, so the Post Type editor
7
+ * inspects the live schema and generates config snippets for changes.
8
+ */
9
+ export interface PostTypeFieldSummary {
10
+ name: string
11
+ type: string
12
+ required: boolean
13
+ relationTo?: string | string[]
14
+ options?: unknown[]
15
+ }
16
+
17
+ export interface PostTypeDetail {
18
+ slug: string
19
+ singular: string
20
+ plural: string
21
+ description: string | null
22
+ urlPrefix: string
23
+ icon: string | null
24
+ color: string | null
25
+ fields: PostTypeFieldSummary[]
26
+ }
27
+
28
+ interface CollectionDetailRow {
29
+ slug: string
30
+ labels: { singular: string; plural: string }
31
+ type: string
32
+ urlPrefix: string
33
+ description: string | null
34
+ icon: string | null
35
+ color: string | null
36
+ hidden: boolean
37
+ fields: PostTypeFieldSummary[]
38
+ }
39
+
40
+ export async function fetchPostTypeDetail(slug: string): Promise<PostTypeDetail | null> {
41
+ const res = await cmsApi<CollectionDetailRow[]>('/collections')
42
+ if (res.error || !res.data) {
43
+ throw new Error(res.error ?? 'Failed to load collections')
44
+ }
45
+ const row = res.data.find((c) => c.slug === slug)
46
+ if (!row) return null
47
+ return {
48
+ slug: row.slug,
49
+ singular: row.labels?.singular ?? slug,
50
+ plural: row.labels?.plural ?? slug,
51
+ description: row.description ?? null,
52
+ urlPrefix: row.urlPrefix || slug,
53
+ icon: row.icon ?? null,
54
+ color: row.color ?? null,
55
+ fields: Array.isArray(row.fields) ? row.fields : [],
56
+ }
57
+ }
58
+
59
+ /** Friendly display names for cms-core field types (design's type badges). */
60
+ export const FIELD_TYPE_LABELS: Record<string, string> = {
61
+ text: 'Short Text',
62
+ richText: 'Rich Text',
63
+ number: 'Number',
64
+ boolean: 'Boolean',
65
+ date: 'Date',
66
+ slug: 'Slug',
67
+ select: 'Select',
68
+ relationship: 'Relation',
69
+ media: 'Media',
70
+ json: 'JSON',
71
+ color: 'Color',
72
+ url: 'URL',
73
+ email: 'Email',
74
+ array: 'List',
75
+ blocks: 'Blocks',
76
+ }
77
+
78
+ export function fieldTypeLabel(type: string): string {
79
+ return FIELD_TYPE_LABELS[type] ?? type
80
+ }
81
+
82
+ /** "clientName" / "client_name" → "Client Name" for display. */
83
+ export function humanizeFieldName(name: string): string {
84
+ return name
85
+ .replace(/[_-]+/g, ' ')
86
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
87
+ .replace(/\s+/g, ' ')
88
+ .trim()
89
+ .replace(/\b\w/g, (c) => c.toUpperCase())
90
+ }
91
+
92
+ /** "Client Name" → "clientName" for generated field keys. */
93
+ export function fieldKeyFromName(name: string): string {
94
+ const words = name
95
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
96
+ .trim()
97
+ .split(/\s+/)
98
+ .filter(Boolean)
99
+ if (words.length === 0) return ''
100
+ return words
101
+ .map((w, i) => (i === 0 ? w.toLowerCase() : w[0]!.toUpperCase() + w.slice(1).toLowerCase()))
102
+ .join('')
103
+ }
104
+
105
+ export interface FieldSnippetInput {
106
+ key: string
107
+ label: string
108
+ type: string
109
+ required: boolean
110
+ helpText?: string
111
+ }
112
+
113
+ /**
114
+ * Paste-ready field entry for the collection's `fields` map in
115
+ * `actuate.config.ts` — same escape rules as the New Post Type snippet.
116
+ */
117
+ export function renderFieldSnippet(input: FieldSnippetInput): string {
118
+ const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
119
+ const parts = [`type: '${input.type}'`, `label: '${esc(input.label)}'`]
120
+ if (input.required) parts.push('required: true')
121
+ if (input.helpText?.trim()) parts.push(`admin: { description: '${esc(input.helpText.trim())}' }`)
122
+ if (input.type === 'select') {
123
+ parts.push(`options: [{ label: 'Option A', value: 'option-a' }]`)
124
+ }
125
+ if (input.type === 'relationship') {
126
+ parts.push(`relationTo: 'posts'`)
127
+ }
128
+ return `${input.key}: { ${parts.join(', ')} },`
129
+ }