@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
@@ -1,7 +1,20 @@
1
1
  'use client'
2
2
 
3
- import { AlertTriangle, Layers, Loader2, RefreshCw } from 'lucide-react'
3
+ import { useMemo, useState } from 'react'
4
+ import { AlertTriangle, Layers, Pencil, RefreshCw, Trash2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { cmsApi } from '../../lib/api.js'
4
7
  import { useApiData } from '../../lib/useApiData.js'
8
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
9
+ import { EmptyState } from '../../components/ui/EmptyState.js'
10
+ import { FilterPill } from '../../components/ui/FilterPill.js'
11
+ import { Input } from '../../components/ui/Input.js'
12
+ import { Modal } from '../../components/ui/Modal.js'
13
+ import { Button } from '../../components/ui/Button.js'
14
+ import { RowActions, RowActionButton } from '../../components/ui/RowActions.js'
15
+ import { SearchInput } from '../../components/ui/SearchInput.js'
16
+ import { Select } from '../../components/ui/Select.js'
17
+ import { Skeleton } from '../../components/ui/Skeleton.js'
5
18
 
6
19
  interface PageTemplate {
7
20
  id: string
@@ -10,6 +23,25 @@ interface PageTemplate {
10
23
  category?: string
11
24
  builtIn?: boolean
12
25
  updatedAt?: string
26
+ thumbnail?: string | null
27
+ tree?: { children?: unknown[] } | null
28
+ }
29
+
30
+ type CategoryId = 'content' | 'landing' | 'utility'
31
+
32
+ const CATEGORIES: Record<CategoryId, { label: string; dot: string; text: string }> = {
33
+ content: { label: 'Content', dot: 'bg-[var(--acc)]', text: 'text-[var(--acc)]' },
34
+ landing: { label: 'Landing', dot: 'bg-[var(--suc)]', text: 'text-[var(--suc)]' },
35
+ utility: { label: 'Utility', dot: 'bg-[var(--warn)]', text: 'text-[var(--warn)]' },
36
+ }
37
+
38
+ function categoryOf(template: PageTemplate): CategoryId {
39
+ const cat = template.category
40
+ return cat === 'landing' || cat === 'utility' ? cat : 'content'
41
+ }
42
+
43
+ function sectionCountOf(template: PageTemplate): number {
44
+ return Array.isArray(template.tree?.children) ? template.tree.children.length : 0
13
45
  }
14
46
 
15
47
  export interface PageTemplatesProps {
@@ -18,94 +50,345 @@ export interface PageTemplatesProps {
18
50
 
19
51
  export function PageTemplates({ onNavigate }: PageTemplatesProps) {
20
52
  const { data, loading, error, refetch } = useApiData<PageTemplate[]>('/page-templates')
21
- const templates = data ?? []
53
+ const templates = useMemo(() => data ?? [], [data])
22
54
 
23
- if (loading) {
24
- return (
25
- <div className="flex h-64 items-center justify-center p-4" role="status" aria-live="polite">
26
- <Loader2 className="text-primary h-6 w-6 animate-spin" />
27
- <span className="sr-only">Loading page templates</span>
28
- </div>
29
- )
55
+ const [search, setSearch] = useState('')
56
+ const [category, setCategory] = useState<CategoryId | 'all'>('all')
57
+ const [editTarget, setEditTarget] = useState<PageTemplate | null>(null)
58
+ const [deleteTarget, setDeleteTarget] = useState<PageTemplate | null>(null)
59
+
60
+ const filtered = useMemo(() => {
61
+ const q = search.trim().toLowerCase()
62
+ return templates.filter((t) => {
63
+ const matchesQuery =
64
+ !q ||
65
+ (t.name ?? '').toLowerCase().includes(q) ||
66
+ (t.description ?? '').toLowerCase().includes(q)
67
+ const matchesCategory = category === 'all' || categoryOf(t) === category
68
+ return matchesQuery && matchesCategory
69
+ })
70
+ }, [templates, search, category])
71
+
72
+ async function confirmDelete() {
73
+ if (!deleteTarget) return
74
+ const res = await cmsApi(`/page-templates/${deleteTarget.id}`, { method: 'DELETE' })
75
+ if (res.error) {
76
+ toast.error(res.error)
77
+ } else {
78
+ toast.success('Template deleted')
79
+ refetch()
80
+ }
30
81
  }
31
82
 
32
83
  return (
33
84
  <div className="p-4 pr-8">
34
85
  {error && (
35
- <div className="border-border bg-card mb-4 flex items-center gap-3 rounded-lg border p-3">
36
- <AlertTriangle className="text-muted-foreground h-5 w-5 shrink-0" />
37
- <span className="text-foreground flex-1 text-sm">{error}</span>
86
+ <div className="mb-4 flex items-center gap-3 rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--card)] p-3">
87
+ <AlertTriangle className="h-5 w-5 shrink-0 text-[var(--muted)]" aria-hidden />
88
+ <span className="flex-1 text-sm text-[var(--txt)]">{error}</span>
38
89
  <button
39
90
  type="button"
40
91
  onClick={refetch}
41
- className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm"
92
+ className="rounded-md border border-[var(--bdr)] px-3 py-1 text-sm text-[var(--txt)] hover:bg-[var(--acc-l)]"
42
93
  >
43
94
  Retry
44
95
  </button>
45
96
  </div>
46
97
  )}
47
98
 
48
- <div className="mb-4 flex items-center justify-between">
99
+ <div className="mb-4 flex items-center justify-between gap-4">
49
100
  <div>
50
101
  <h1 className="text-foreground mb-1">Page Templates</h1>
51
- <p className="text-muted-foreground text-sm">
102
+ <p className="text-sm text-[var(--sub)]">
52
103
  {templates.length} saved template{templates.length === 1 ? '' : 's'}
53
104
  </p>
54
105
  </div>
55
106
  <button
56
107
  type="button"
57
108
  onClick={refetch}
58
- className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium"
109
+ className="inline-flex items-center gap-2 rounded-md border border-[var(--bdr)] px-3 py-2 text-sm font-medium text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)]"
59
110
  >
60
- <RefreshCw className="h-4 w-4" />
111
+ <RefreshCw className="h-4 w-4" aria-hidden />
61
112
  Refresh
62
113
  </button>
63
114
  </div>
64
115
 
65
- {templates.length === 0 ? (
66
- <div className="border-border bg-card rounded-lg border p-8 text-center">
67
- <Layers className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
68
- <h2 className="text-foreground mb-1 text-lg font-medium">No page templates yet</h2>
69
- <p className="text-muted-foreground mb-4 text-sm">
70
- Built-in templates are seeded by the CMS when the templates API is available.
71
- </p>
72
- <button
73
- type="button"
74
- onClick={() => onNavigate?.('/saved-sections')}
75
- className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:opacity-90"
76
- >
77
- View Saved Sections
78
- </button>
116
+ {!loading && templates.length > 0 && (
117
+ <div className="mb-4 flex flex-wrap items-center gap-2">
118
+ <div className="max-w-xs min-w-52 flex-1">
119
+ <SearchInput value={search} onChange={setSearch} placeholder="Search templates…" />
120
+ </div>
121
+ <div className="flex gap-1.5" role="group" aria-label="Filter templates by category">
122
+ <FilterPill active={category === 'all'} onClick={() => setCategory('all')}>
123
+ All
124
+ </FilterPill>
125
+ {(Object.keys(CATEGORIES) as CategoryId[]).map((cat) => (
126
+ <FilterPill key={cat} active={category === cat} onClick={() => setCategory(cat)}>
127
+ {CATEGORIES[cat].label}
128
+ </FilterPill>
129
+ ))}
130
+ </div>
131
+ <span className="ml-auto text-[12.5px] whitespace-nowrap text-[var(--muted)]">
132
+ {filtered.length} of {templates.length}
133
+ </span>
79
134
  </div>
80
- ) : (
81
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
82
- {templates.map((template) => (
83
- <div key={template.id} className="border-border bg-card rounded-lg border p-4">
84
- <div className="mb-3 flex items-start justify-between gap-3">
85
- <div>
86
- <h2 className="text-foreground text-base font-medium">
87
- {template.name ?? 'Untitled template'}
88
- </h2>
89
- <p className="text-muted-foreground mt-1 text-sm">
90
- {template.description ?? 'No description provided.'}
91
- </p>
92
- </div>
93
- {template.builtIn && (
94
- <span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs">
95
- Built-in
96
- </span>
97
- )}
98
- </div>
99
- <div className="text-muted-foreground flex items-center justify-between text-sm">
100
- <span>{template.category ?? 'content'}</span>
101
- <span>
102
- {template.updatedAt ? new Date(template.updatedAt).toLocaleDateString() : ''}
103
- </span>
104
- </div>
105
- </div>
135
+ )}
136
+
137
+ {loading ? (
138
+ <div
139
+ className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
140
+ role="status"
141
+ aria-live="polite"
142
+ aria-label="Loading page templates"
143
+ >
144
+ {Array.from({ length: 6 }, (_, i) => (
145
+ <Skeleton key={i} variant="card" />
106
146
  ))}
107
147
  </div>
148
+ ) : templates.length === 0 ? (
149
+ <EmptyState
150
+ icon={<Layers size={24} aria-hidden />}
151
+ title="No page templates yet"
152
+ description="Built-in templates are seeded by the CMS when the templates API is available."
153
+ actionLabel="View Saved Sections"
154
+ onAction={() => onNavigate?.('/saved-sections')}
155
+ />
156
+ ) : filtered.length === 0 ? (
157
+ <p className="py-12 text-center text-sm text-[var(--muted)]">
158
+ No templates match your search.
159
+ </p>
160
+ ) : (
161
+ <ul
162
+ className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
163
+ aria-label="Page templates"
164
+ >
165
+ {filtered.map((template) => (
166
+ <TemplateCard
167
+ key={template.id}
168
+ template={template}
169
+ onEdit={() => setEditTarget(template)}
170
+ onDelete={() => setDeleteTarget(template)}
171
+ />
172
+ ))}
173
+ </ul>
108
174
  )}
175
+
176
+ <TemplateEditModal
177
+ template={editTarget}
178
+ onClose={() => setEditTarget(null)}
179
+ onSaved={() => {
180
+ setEditTarget(null)
181
+ refetch()
182
+ }}
183
+ />
184
+
185
+ <ConfirmDialog
186
+ open={deleteTarget !== null}
187
+ onClose={() => setDeleteTarget(null)}
188
+ onConfirm={() => void confirmDelete()}
189
+ title="Delete template?"
190
+ description={`"${deleteTarget?.name ?? 'This template'}" will be permanently removed. Pages already created from it are not affected.`}
191
+ confirmLabel="Delete"
192
+ destructive
193
+ />
109
194
  </div>
110
195
  )
111
196
  }
197
+
198
+ function TemplateCard({
199
+ template,
200
+ onEdit,
201
+ onDelete,
202
+ }: {
203
+ template: PageTemplate
204
+ onEdit: () => void
205
+ onDelete: () => void
206
+ }) {
207
+ const cat = CATEGORIES[categoryOf(template)]
208
+ const sections = sectionCountOf(template)
209
+ const name = template.name ?? 'Untitled template'
210
+ const updated = template.updatedAt ? new Date(template.updatedAt).toLocaleDateString() : null
211
+
212
+ return (
213
+ <li className="group relative flex flex-col overflow-hidden rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--card)] shadow-[var(--shd)] transition-[border-color,box-shadow,transform] duration-[var(--motion-fast)] focus-within:border-[var(--acc-m)] focus-within:shadow-[var(--shd2)] hover:-translate-y-0.5 hover:border-[var(--acc-m)] hover:shadow-[var(--shd2)] motion-reduce:hover:translate-y-0">
214
+ <TemplatePreview template={template} sections={sections} />
215
+
216
+ <div className="flex flex-1 flex-col p-4 pb-0">
217
+ <div className="flex items-start justify-between gap-3">
218
+ <h2 className="text-[15px] leading-snug font-medium text-[var(--txt)]">{name}</h2>
219
+ {template.builtIn && (
220
+ <span className="shrink-0 rounded-full border border-[var(--bdr)] bg-[var(--bg)] px-2 py-0.5 text-[10.5px] font-medium whitespace-nowrap text-[var(--sub)]">
221
+ Built-in
222
+ </span>
223
+ )}
224
+ </div>
225
+ <p className="mt-2 min-h-10 text-[12.5px] leading-relaxed text-[var(--sub)]">
226
+ {template.description ?? 'No description provided.'}
227
+ </p>
228
+
229
+ <div className="relative mt-3 flex h-[46px] items-center justify-between border-t border-[var(--bdr)] py-2.5">
230
+ <span
231
+ className={`inline-flex items-center gap-1.5 text-[11.5px] font-medium ${cat.text}`}
232
+ >
233
+ <span aria-hidden className={`h-[7px] w-[7px] shrink-0 rounded-full ${cat.dot}`} />
234
+ {cat.label}
235
+ </span>
236
+ <span className="text-[11.5px] text-[var(--muted)] transition-opacity duration-[var(--motion-fast)] group-focus-within:opacity-0 group-hover:opacity-0">
237
+ {sections} section{sections === 1 ? '' : 's'}
238
+ {updated ? ` · ${updated}` : ''}
239
+ </span>
240
+ {!template.builtIn && (
241
+ <RowActions className="absolute top-1/2 right-0 -translate-y-1/2">
242
+ <RowActionButton aria-label={`Edit ${name}`} onClick={onEdit}>
243
+ <Pencil size={14} aria-hidden />
244
+ </RowActionButton>
245
+ <RowActionButton aria-label={`Delete ${name}`} danger onClick={onDelete}>
246
+ <Trash2 size={14} aria-hidden />
247
+ </RowActionButton>
248
+ </RowActions>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </li>
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Card preview area. Uses the stored thumbnail when one exists; otherwise
258
+ * renders an honest wireframe placeholder built from the template's section
259
+ * count (a hero band plus one bar per section) — no fake screenshots.
260
+ */
261
+ function TemplatePreview({ template, sections }: { template: PageTemplate; sections: number }) {
262
+ if (template.thumbnail) {
263
+ return (
264
+ <div className="h-28 shrink-0 overflow-hidden border-b border-[var(--bdr)] bg-[var(--bg)]">
265
+ <img
266
+ src={template.thumbnail}
267
+ alt={`Preview of ${template.name ?? 'template'}`}
268
+ className="h-full w-full object-cover"
269
+ />
270
+ </div>
271
+ )
272
+ }
273
+
274
+ const bars = Math.max(0, Math.min(sections - 1, 3))
275
+ return (
276
+ <div
277
+ aria-hidden
278
+ className="flex h-28 shrink-0 flex-col gap-1.5 border-b border-[var(--bdr)] bg-[var(--bg)] p-3"
279
+ >
280
+ <div className="h-9 rounded-[5px] bg-[var(--acc-l)]" />
281
+ {Array.from({ length: bars }, (_, i) => (
282
+ <div
283
+ key={i}
284
+ className="h-3.5 rounded-[4px] bg-[var(--bdr)]"
285
+ style={{ width: `${88 - i * 14}%` }}
286
+ />
287
+ ))}
288
+ </div>
289
+ )
290
+ }
291
+
292
+ function TemplateEditModal({
293
+ template,
294
+ onClose,
295
+ onSaved,
296
+ }: {
297
+ template: PageTemplate | null
298
+ onClose: () => void
299
+ onSaved: () => void
300
+ }) {
301
+ // Keyed remount seeds the form state from the selected template each time
302
+ // the modal opens, without effect-based state syncing.
303
+ if (!template) return null
304
+ return (
305
+ <TemplateEditForm key={template.id} template={template} onClose={onClose} onSaved={onSaved} />
306
+ )
307
+ }
308
+
309
+ function TemplateEditForm({
310
+ template,
311
+ onClose,
312
+ onSaved,
313
+ }: {
314
+ template: PageTemplate
315
+ onClose: () => void
316
+ onSaved: () => void
317
+ }) {
318
+ const [name, setName] = useState(template.name ?? '')
319
+ const [description, setDescription] = useState(template.description ?? '')
320
+ const [category, setCategory] = useState<CategoryId>(categoryOf(template))
321
+ const [saving, setSaving] = useState(false)
322
+
323
+ async function handleSave() {
324
+ if (!name.trim()) {
325
+ toast.error('Template name is required')
326
+ return
327
+ }
328
+ setSaving(true)
329
+ const res = await cmsApi(`/page-templates/${template.id}`, {
330
+ method: 'PUT',
331
+ body: JSON.stringify({
332
+ name: name.trim(),
333
+ description: description.trim() || null,
334
+ category,
335
+ }),
336
+ })
337
+ setSaving(false)
338
+ if (res.error) {
339
+ toast.error(res.error)
340
+ } else {
341
+ toast.success('Template updated')
342
+ onSaved()
343
+ }
344
+ }
345
+
346
+ return (
347
+ <Modal
348
+ open
349
+ onClose={onClose}
350
+ title="Edit template"
351
+ actions={
352
+ <>
353
+ <Button variant="ghost" onClick={onClose} disabled={saving}>
354
+ Cancel
355
+ </Button>
356
+ <Button variant="primary" onClick={() => void handleSave()} loading={saving}>
357
+ Save changes
358
+ </Button>
359
+ </>
360
+ }
361
+ >
362
+ <div className="flex flex-col gap-4">
363
+ <label className="flex flex-col gap-1.5">
364
+ <span className="text-sm font-medium text-[var(--txt)]">Name</span>
365
+ <Input
366
+ value={name}
367
+ onChange={(e) => setName(e.target.value)}
368
+ placeholder="Template name"
369
+ />
370
+ </label>
371
+ <label className="flex flex-col gap-1.5">
372
+ <span className="text-sm font-medium text-[var(--txt)]">Description</span>
373
+ <textarea
374
+ value={description}
375
+ onChange={(e) => setDescription(e.target.value)}
376
+ rows={3}
377
+ placeholder="What this template is for…"
378
+ className="w-full resize-y rounded-[var(--r-control)] border border-[var(--bdr)] bg-[var(--input-background)] px-3 py-2 text-sm text-[var(--txt)] placeholder:text-[var(--muted)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
379
+ />
380
+ </label>
381
+ <label className="flex flex-col gap-1.5">
382
+ <span className="text-sm font-medium text-[var(--txt)]">Category</span>
383
+ <Select value={category} onChange={(e) => setCategory(e.target.value as CategoryId)}>
384
+ {(Object.keys(CATEGORIES) as CategoryId[]).map((cat) => (
385
+ <option key={cat} value={cat}>
386
+ {CATEGORIES[cat].label}
387
+ </option>
388
+ ))}
389
+ </Select>
390
+ </label>
391
+ </div>
392
+ </Modal>
393
+ )
394
+ }
@@ -48,6 +48,12 @@ import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
48
48
  interface PageSectionEditorProps {
49
49
  /** undefined when creating a new page (`/pages/new`) */
50
50
  documentId?: string
51
+ /**
52
+ * Parent page to preselect on a new page — set when arriving via
53
+ * "Add sub-page" in the Pages tree view (`/pages/new?parent=<id>`).
54
+ * Ignored when `documentId` is present.
55
+ */
56
+ initialParentId?: string | null
51
57
  config?: any
52
58
  session?: any
53
59
  onNavigate: (path: string) => void
@@ -73,6 +79,7 @@ function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
73
79
 
74
80
  export function PageSectionEditor({
75
81
  documentId,
82
+ initialParentId,
76
83
  config,
77
84
  session,
78
85
  onNavigate,
@@ -136,7 +143,7 @@ export function PageSectionEditor({
136
143
  }
137
144
 
138
145
  if (!documentId) {
139
- setPage(emptyPage())
146
+ setPage(emptyPage(initialParentId ?? null))
140
147
  setLoading(false)
141
148
  return
142
149
  }
@@ -160,7 +167,7 @@ export function PageSectionEditor({
160
167
  return () => {
161
168
  cancelled = true
162
169
  }
163
- }, [documentId, schemaError])
170
+ }, [documentId, initialParentId, schemaError])
164
171
 
165
172
  // ─── Unsaved-changes guard (browser unload) ──────────────────────────
166
173
  useEffect(() => {
@@ -5,6 +5,7 @@ import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
7
7
  import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
8
+ import { cmsApi } from '../../lib/api.js'
8
9
  import type { EditorPage } from '../../lib/page-editor-service.js'
9
10
 
10
11
  const SEO_TITLE_MIN = 30
@@ -26,7 +27,12 @@ const INPUT =
26
27
 
27
28
  const slugify = normalizeSlug
28
29
 
29
- /** Edits page-level fields: title, slug, path, and SEO. */
30
+ interface ParentOption {
31
+ id: string
32
+ title: string
33
+ }
34
+
35
+ /** Edits page-level fields: title, slug, path, parent page, and SEO. */
30
36
  export function PageSettingsModal({
31
37
  open,
32
38
  page,
@@ -37,21 +43,52 @@ export function PageSettingsModal({
37
43
  const [title, setTitle] = useState(page.title)
38
44
  const [slug, setSlug] = useState(page.slug)
39
45
  const [path, setPath] = useState(page.path)
46
+ const [parentPageId, setParentPageId] = useState(page.parentPageId ?? '')
40
47
  const [seoTitle, setSeoTitle] = useState(page.seoTitle)
41
48
  const [seoDescription, setSeoDescription] = useState(page.seoDescription)
42
49
  // Auto-derive slug/path from title until the user edits slug manually.
43
50
  const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
51
+ const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
44
52
 
45
53
  useEffect(() => {
46
54
  if (!open) return
47
55
  setTitle(page.title)
48
56
  setSlug(page.slug)
49
57
  setPath(page.path)
58
+ setParentPageId(page.parentPageId ?? '')
50
59
  setSeoTitle(page.seoTitle)
51
60
  setSeoDescription(page.seoDescription)
52
61
  setSlugTouched(Boolean(page.slug))
53
62
  }, [open, page])
54
63
 
64
+ // Load the parent-page choices lazily the first time the modal opens.
65
+ // The page itself is excluded (a page can't be its own parent); deeper
66
+ // cycle protection lives in the tree builder, which breaks bad chains.
67
+ useEffect(() => {
68
+ if (!open || parentOptions !== null) return
69
+ let cancelled = false
70
+ cmsApi<{ docs: Array<{ id: string; title: string | null }> }>(
71
+ '/collections/pages?pageSize=500&sort=updatedAt&order=desc',
72
+ )
73
+ .then((res) => {
74
+ if (cancelled) return
75
+ const docs = res.data?.docs ?? []
76
+ const options = docs
77
+ .filter((d) => d.id !== page.id)
78
+ .map((d) => ({ id: d.id, title: d.title ?? '(untitled)' }))
79
+ .sort((a, b) => a.title.localeCompare(b.title))
80
+ setParentOptions(options)
81
+ })
82
+ .catch(() => {
83
+ // Leave the picker in its loading state note; the rest of the
84
+ // settings form stays fully usable.
85
+ if (!cancelled) setParentOptions([])
86
+ })
87
+ return () => {
88
+ cancelled = true
89
+ }
90
+ }, [open, parentOptions, page.id])
91
+
55
92
  function handleTitle(next: string) {
56
93
  setTitle(next)
57
94
  if (!slugTouched) {
@@ -65,7 +102,14 @@ export function PageSettingsModal({
65
102
 
66
103
  function handleSave() {
67
104
  if (slugError) return
68
- onSave({ title, slug, path: path || (slug ? `/${slug}` : '/'), seoTitle, seoDescription })
105
+ onSave({
106
+ title,
107
+ slug,
108
+ path: path || (slug ? `/${slug}` : '/'),
109
+ parentPageId: parentPageId || null,
110
+ seoTitle,
111
+ seoDescription,
112
+ })
69
113
  onClose()
70
114
  }
71
115
 
@@ -135,6 +179,35 @@ export function PageSettingsModal({
135
179
  />
136
180
  </div>
137
181
  </div>
182
+ <div>
183
+ <label className={LABEL} htmlFor="ps-parent">
184
+ Parent page
185
+ </label>
186
+ <select
187
+ id="ps-parent"
188
+ className={INPUT}
189
+ value={parentPageId}
190
+ disabled={!canEdit || parentOptions === null}
191
+ onChange={(e) => setParentPageId(e.target.value)}
192
+ >
193
+ <option value="">None — top level</option>
194
+ {/* Keep a preselected parent (e.g. from "Add sub-page") valid
195
+ while the options load or if it's missing from the list. */}
196
+ {parentPageId && !(parentOptions ?? []).some((p) => p.id === parentPageId) && (
197
+ <option value={parentPageId}>
198
+ {parentOptions === null ? 'Loading…' : 'Current parent'}
199
+ </option>
200
+ )}
201
+ {(parentOptions ?? []).map((p) => (
202
+ <option key={p.id} value={p.id}>
203
+ {p.title}
204
+ </option>
205
+ ))}
206
+ </select>
207
+ <p className="text-muted-foreground mt-1 text-xs">
208
+ Nests this page under a parent in the Pages tree view.
209
+ </p>
210
+ </div>
138
211
  <div>
139
212
  <div className="mb-1 flex items-center justify-between">
140
213
  <label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">