@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
@@ -7,6 +7,8 @@ import {
7
7
  ChevronRight,
8
8
  ChevronUp,
9
9
  Copy,
10
+ List,
11
+ ListTree,
10
12
  PenLine,
11
13
  Plus,
12
14
  Search,
@@ -33,10 +35,12 @@ import {
33
35
  type PageStatus,
34
36
  type PageTag,
35
37
  } from '../../lib/pages-service.js'
38
+ import { buildPageTree, defaultExpandedIds, flattenPageTree } from '../../lib/pages-tree.js'
36
39
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
37
40
  import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
38
41
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
39
42
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
43
+ import { PagesTreeTable } from './PagesTreeTable.js'
40
44
 
41
45
  /**
42
46
  * Pages management surface — table with a tag-chip filter bar, group-by,
@@ -56,8 +60,19 @@ interface PagesListViewProps {
56
60
 
57
61
  type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
58
62
  type Dir = 'asc' | 'desc'
63
+ type ViewMode = 'list' | 'tree'
59
64
 
60
65
  const PAGE_SIZE = 25
66
+ const VIEW_MODE_KEY = 'actuate.pages.viewMode'
67
+
68
+ function readStoredViewMode(): ViewMode {
69
+ if (typeof window === 'undefined') return 'list'
70
+ try {
71
+ return localStorage.getItem(VIEW_MODE_KEY) === 'tree' ? 'tree' : 'list'
72
+ } catch {
73
+ return 'list'
74
+ }
75
+ }
61
76
 
62
77
  export function PagesListView({ onNavigate }: PagesListViewProps) {
63
78
  const [search, setSearch] = useState('')
@@ -69,6 +84,10 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
69
84
  const [dir, setDir] = useState<Dir>('asc')
70
85
  const [page, setPage] = useState(1)
71
86
  const [selected, setSelected] = useState<Set<string>>(new Set())
87
+ const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode)
88
+ // null = "not yet touched" → falls back to the default expand state
89
+ // (all root nodes with children open) computed from the current tree.
90
+ const [expandedOverride, setExpandedOverride] = useState<Set<string> | null>(null)
72
91
 
73
92
  const [dataset, setDataset] = useState<PagesDataset | null>(null)
74
93
  const [loading, setLoading] = useState(true)
@@ -154,6 +173,49 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
154
173
 
155
174
  const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
156
175
 
176
+ // ─── Tree mode ─────────────────────────────────────────────────────
177
+
178
+ const treeMode = viewMode === 'tree'
179
+ const treeRoots = useMemo(() => buildPageTree(allPages), [allPages])
180
+ const defaultExpanded = useMemo(() => defaultExpandedIds(treeRoots), [treeRoots])
181
+ const expanded = expandedOverride ?? defaultExpanded
182
+
183
+ // Per the design, an active search or status filter flattens the tree:
184
+ // every match renders at depth 0 and expand state is ignored. (Tag
185
+ // filters and group-by don't apply in tree mode — the hierarchy *is*
186
+ // the grouping.)
187
+ const treeFiltersActive = debouncedSearch !== '' || status !== 'all'
188
+ const treeRows = useMemo(() => {
189
+ if (treeFiltersActive) {
190
+ return applyPagesQuery(allPages, {
191
+ status,
192
+ search: debouncedSearch,
193
+ sortBy: 'title',
194
+ sortDirection: 'asc',
195
+ }).map((p) => ({ page: p, depth: 0, hasChildren: false }))
196
+ }
197
+ return flattenPageTree(treeRoots, expanded)
198
+ }, [treeFiltersActive, allPages, status, debouncedSearch, treeRoots, expanded])
199
+
200
+ const toggleExpand = (id: string) =>
201
+ setExpandedOverride((prev) => {
202
+ const next = new Set(prev ?? defaultExpanded)
203
+ if (next.has(id)) next.delete(id)
204
+ else next.add(id)
205
+ return next
206
+ })
207
+
208
+ const changeViewMode = (mode: ViewMode) => {
209
+ if (mode === viewMode) return
210
+ setViewMode(mode)
211
+ setSelected(new Set())
212
+ try {
213
+ localStorage.setItem(VIEW_MODE_KEY, mode)
214
+ } catch {
215
+ /* storage unavailable (private mode) — the toggle still works for the session */
216
+ }
217
+ }
218
+
157
219
  const handleSort = useCallback(
158
220
  (field: SortBy) => {
159
221
  if (sortBy === field) {
@@ -198,16 +260,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
198
260
  return next
199
261
  })
200
262
 
201
- const allOnPageSelected = pages.length > 0 && pages.every((p) => selected.has(p.id))
263
+ // Selection operates over the rows currently visible in the active
264
+ // view mode (paged flat list, or visible tree rows).
265
+ const visiblePages = useMemo(
266
+ () => (treeMode ? treeRows.map((r) => r.page) : pages),
267
+ [treeMode, treeRows, pages],
268
+ )
269
+
270
+ const allOnPageSelected = visiblePages.length > 0 && visiblePages.every((p) => selected.has(p.id))
202
271
  const toggleAllOnPage = (checked: boolean) =>
203
272
  setSelected((s) => {
204
273
  const next = new Set(s)
205
- if (checked) for (const p of pages) next.add(p.id)
206
- else for (const p of pages) next.delete(p.id)
274
+ if (checked) for (const p of visiblePages) next.add(p.id)
275
+ else for (const p of visiblePages) next.delete(p.id)
207
276
  return next
208
277
  })
209
278
 
210
- const selectedPages = useMemo(() => pages.filter((p) => selected.has(p.id)), [pages, selected])
279
+ const selectedPages = useMemo(
280
+ () => visiblePages.filter((p) => selected.has(p.id)),
281
+ [visiblePages, selected],
282
+ )
211
283
  const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
212
284
  const blockedHomepage = selectedPages.length !== deletablePages.length
213
285
 
@@ -342,7 +414,9 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
342
414
  <div className="flex flex-col gap-1">
343
415
  <h1 className="text-foreground">Pages</h1>
344
416
  <p className="text-muted-foreground text-sm">
345
- {grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
417
+ {treeMode
418
+ ? `${grandTotal} page${grandTotal === 1 ? '' : 's'} total`
419
+ : `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
346
420
  </p>
347
421
  </div>
348
422
  <button
@@ -372,17 +446,21 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
372
446
  />
373
447
  </div>
374
448
 
375
- <FilterSelect
376
- label="Group by"
377
- value={groupBy}
378
- onChange={(v) => setGroupBy(v as PageGroupBy)}
379
- options={[
380
- { value: 'none', label: 'None' },
381
- { value: 'tag', label: 'Tag' },
382
- { value: 'status', label: 'Status' },
383
- { value: 'author', label: 'Author' },
384
- ]}
385
- />
449
+ {/* Group-by only applies to the flat list — in tree mode the
450
+ hierarchy is the grouping. */}
451
+ {!treeMode && (
452
+ <FilterSelect
453
+ label="Group by"
454
+ value={groupBy}
455
+ onChange={(v) => setGroupBy(v as PageGroupBy)}
456
+ options={[
457
+ { value: 'none', label: 'None' },
458
+ { value: 'tag', label: 'Tag' },
459
+ { value: 'status', label: 'Status' },
460
+ { value: 'author', label: 'Author' },
461
+ ]}
462
+ />
463
+ )}
386
464
 
387
465
  <FilterSelect
388
466
  label="Status"
@@ -399,45 +477,54 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
399
477
  ]}
400
478
  />
401
479
 
480
+ <ViewModeToggle mode={viewMode} onChange={changeViewMode} />
481
+
402
482
  <span className="text-muted-foreground ml-auto text-sm tabular-nums">
403
- {total} of {grandTotal}
483
+ {treeMode
484
+ ? treeFiltersActive
485
+ ? `${treeRows.length} result${treeRows.length === 1 ? '' : 's'}`
486
+ : `${grandTotal} page${grandTotal === 1 ? '' : 's'}`
487
+ : `${total} of ${grandTotal}`}
404
488
  </span>
405
489
  </div>
406
490
 
407
- {/* Tag filter bar */}
408
- <div className="flex flex-wrap items-center gap-2">
409
- <span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
410
- Tags
411
- </span>
412
- {tags.map((t) => (
413
- <PageTagChip
414
- key={t.slug}
415
- name={t.name}
416
- color={t.color}
417
- count={t.pageCount}
418
- selected={selectedTags.has(t.slug)}
419
- onToggle={() => toggleTag(t.slug)}
420
- />
421
- ))}
422
- {selectedTags.size > 0 && (
491
+ {/* Tag filter bar (flat list only — tree mode replaces tag grouping
492
+ with the page hierarchy) */}
493
+ {!treeMode && (
494
+ <div className="flex flex-wrap items-center gap-2">
495
+ <span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
496
+ Tags
497
+ </span>
498
+ {tags.map((t) => (
499
+ <PageTagChip
500
+ key={t.slug}
501
+ name={t.name}
502
+ color={t.color}
503
+ count={t.pageCount}
504
+ selected={selectedTags.has(t.slug)}
505
+ onToggle={() => toggleTag(t.slug)}
506
+ />
507
+ ))}
508
+ {selectedTags.size > 0 && (
509
+ <button
510
+ type="button"
511
+ onClick={() => setSelectedTags(new Set())}
512
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
513
+ >
514
+ <X className="h-3 w-3" aria-hidden />
515
+ Clear tags
516
+ </button>
517
+ )}
423
518
  <button
424
519
  type="button"
425
- onClick={() => setSelectedTags(new Set())}
426
- className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
520
+ onClick={() => onNavigate('/pages/tags')}
521
+ className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
427
522
  >
428
- <X className="h-3 w-3" aria-hidden />
429
- Clear tags
523
+ <TagIcon className="h-3 w-3" aria-hidden />
524
+ Manage tags
430
525
  </button>
431
- )}
432
- <button
433
- type="button"
434
- onClick={() => onNavigate('/pages/tags')}
435
- className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
436
- >
437
- <TagIcon className="h-3 w-3" aria-hidden />
438
- Manage tags
439
- </button>
440
- </div>
526
+ </div>
527
+ )}
441
528
 
442
529
  {/* Bulk action bar */}
443
530
  {selected.size > 0 && (
@@ -457,147 +544,186 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
457
544
 
458
545
  {/* Table card */}
459
546
  <div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
460
- {/* Horizontal scroll on narrow viewports: the fixed columns keep their
547
+ {treeMode ? (
548
+ <PagesTreeTable
549
+ rows={treeRows}
550
+ loading={loading}
551
+ error={error}
552
+ flattened={treeFiltersActive}
553
+ selected={selected}
554
+ expanded={expanded}
555
+ onToggleExpand={toggleExpand}
556
+ onToggleSelect={toggleOne}
557
+ onToggleAll={toggleAllOnPage}
558
+ onOpen={(p) => onNavigate(`/pages/${p.id}/edit`)}
559
+ onAddSubPage={(p) => onNavigate(`/pages/new?parent=${encodeURIComponent(p.id)}`)}
560
+ onDuplicate={(p) => void runDuplicate(p)}
561
+ onDelete={(p) => setRowDelete(p)}
562
+ onRetry={() => setRefreshToken((t) => t + 1)}
563
+ onClearFilters={clearFilters}
564
+ onNewPage={() => onNavigate('/pages/new')}
565
+ />
566
+ ) : (
567
+ <>
568
+ {/* Horizontal scroll on narrow viewports: the fixed columns keep their
461
569
  width and the table scrolls sideways instead of crushing content. */}
462
- <div className="overflow-x-auto">
463
- <table className="w-full min-w-[800px] table-fixed" role="table" aria-label="Pages">
464
- <colgroup>
465
- <col style={{ width: '40px' }} />
466
- <col />
467
- <col style={{ width: '200px' }} />
468
- <col style={{ width: '170px' }} />
469
- <col style={{ width: '120px' }} />
470
- <col style={{ width: '130px' }} />
471
- <col style={{ width: '80px' }} />
472
- <col style={{ width: '110px' }} />
473
- </colgroup>
474
- <thead className="border-border bg-muted/40 border-b">
475
- <tr className="text-muted-foreground text-xs">
476
- <th scope="col" className="px-3 py-2.5 text-left">
477
- <input
478
- type="checkbox"
479
- aria-label="Select all pages on this page"
480
- checked={allOnPageSelected}
481
- onChange={(e) => toggleAllOnPage(e.target.checked)}
482
- className="border-border accent-primary rounded"
483
- />
484
- </th>
485
- <th scope="col" aria-sort={ariaSortFor('title')} className="px-3 py-2.5 text-left">
486
- <SortHeader
487
- label="Title"
488
- field="title"
489
- current={sortBy}
490
- dir={dir}
491
- onSort={handleSort}
492
- />
493
- </th>
494
- <th scope="col" className="px-3 py-2.5 text-left">
495
- <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
496
- </th>
497
- <th scope="col" aria-sort={ariaSortFor('author')} className="px-3 py-2.5 text-left">
498
- <SortHeader
499
- label="Author"
500
- field="author"
501
- current={sortBy}
502
- dir={dir}
503
- onSort={handleSort}
504
- />
505
- </th>
506
- <th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
507
- <SortHeader
508
- label="Status"
509
- field="status"
510
- current={sortBy}
511
- dir={dir}
512
- onSort={handleSort}
513
- />
514
- </th>
515
- <th
516
- scope="col"
517
- aria-sort={ariaSortFor('modified')}
518
- className="px-3 py-2.5 text-left"
519
- >
520
- <SortHeader
521
- label="Last Modified"
522
- field="modified"
523
- current={sortBy}
524
- dir={dir}
525
- onSort={handleSort}
526
- />
527
- </th>
528
- <th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
529
- <SortHeader
530
- label="SEO"
531
- field="seo"
532
- current={sortBy}
533
- dir={dir}
534
- onSort={handleSort}
535
- />
536
- </th>
537
- <th scope="col" className="px-3 py-2.5 text-right">
538
- <span className="sr-only">Actions</span>
539
- </th>
540
- </tr>
541
- </thead>
542
- <tbody>
543
- {loading && pages.length === 0 && <LoadingRows />}
544
- {!loading && error && (
545
- <tr>
546
- <td colSpan={8} className="px-3 py-12 text-center">
547
- <div className="text-foreground mb-2 text-sm font-medium">{error}</div>
548
- <button
549
- onClick={() => setRefreshToken((t) => t + 1)}
550
- className="text-primary text-sm hover:underline"
570
+ <div className="overflow-x-auto">
571
+ <table className="w-full min-w-[800px] table-fixed" role="table" aria-label="Pages">
572
+ <colgroup>
573
+ <col style={{ width: '40px' }} />
574
+ <col />
575
+ <col style={{ width: '200px' }} />
576
+ <col style={{ width: '170px' }} />
577
+ <col style={{ width: '120px' }} />
578
+ <col style={{ width: '130px' }} />
579
+ <col style={{ width: '80px' }} />
580
+ <col style={{ width: '110px' }} />
581
+ </colgroup>
582
+ <thead className="border-border bg-muted/40 border-b">
583
+ <tr className="text-muted-foreground text-xs">
584
+ <th scope="col" className="px-3 py-2.5 text-left">
585
+ <input
586
+ type="checkbox"
587
+ aria-label="Select all pages on this page"
588
+ checked={allOnPageSelected}
589
+ onChange={(e) => toggleAllOnPage(e.target.checked)}
590
+ className="border-border accent-primary rounded"
591
+ />
592
+ </th>
593
+ <th
594
+ scope="col"
595
+ aria-sort={ariaSortFor('title')}
596
+ className="px-3 py-2.5 text-left"
597
+ >
598
+ <SortHeader
599
+ label="Title"
600
+ field="title"
601
+ current={sortBy}
602
+ dir={dir}
603
+ onSort={handleSort}
604
+ />
605
+ </th>
606
+ <th scope="col" className="px-3 py-2.5 text-left">
607
+ <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
608
+ </th>
609
+ <th
610
+ scope="col"
611
+ aria-sort={ariaSortFor('author')}
612
+ className="px-3 py-2.5 text-left"
551
613
  >
552
- Try again
553
- </button>
554
- </td>
555
- </tr>
556
- )}
557
- {!loading && !error && pages.length === 0 && (
558
- <EmptyRow
559
- filtersActive={filtersAreActive}
560
- onClear={clearFilters}
561
- onNewPage={() => onNavigate('/pages/new')}
562
- />
563
- )}
564
- {!loading &&
565
- !error &&
566
- displayGroups.map((group) => (
567
- <GroupBlock
568
- key={group.key}
569
- showHeader={grouped}
570
- label={group.label}
571
- color={group.color}
572
- count={group.pages.length}
573
- >
574
- {group.pages.map((p) => (
575
- <PageRow
576
- key={p.id}
577
- page={p}
578
- selected={selected.has(p.id)}
579
- resolveTag={(slug) => tagBySlug.get(slug)}
580
- onSelect={() => toggleOne(p.id)}
581
- onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
582
- onDuplicate={() => void runDuplicate(p)}
583
- onDelete={() => setRowDelete(p)}
614
+ <SortHeader
615
+ label="Author"
616
+ field="author"
617
+ current={sortBy}
618
+ dir={dir}
619
+ onSort={handleSort}
584
620
  />
621
+ </th>
622
+ <th
623
+ scope="col"
624
+ aria-sort={ariaSortFor('status')}
625
+ className="px-3 py-2.5 text-left"
626
+ >
627
+ <SortHeader
628
+ label="Status"
629
+ field="status"
630
+ current={sortBy}
631
+ dir={dir}
632
+ onSort={handleSort}
633
+ />
634
+ </th>
635
+ <th
636
+ scope="col"
637
+ aria-sort={ariaSortFor('modified')}
638
+ className="px-3 py-2.5 text-left"
639
+ >
640
+ <SortHeader
641
+ label="Last Modified"
642
+ field="modified"
643
+ current={sortBy}
644
+ dir={dir}
645
+ onSort={handleSort}
646
+ />
647
+ </th>
648
+ <th
649
+ scope="col"
650
+ aria-sort={ariaSortFor('seo')}
651
+ className="px-3 py-2.5 text-left"
652
+ >
653
+ <SortHeader
654
+ label="SEO"
655
+ field="seo"
656
+ current={sortBy}
657
+ dir={dir}
658
+ onSort={handleSort}
659
+ />
660
+ </th>
661
+ <th scope="col" className="px-3 py-2.5 text-right">
662
+ <span className="sr-only">Actions</span>
663
+ </th>
664
+ </tr>
665
+ </thead>
666
+ <tbody>
667
+ {loading && pages.length === 0 && <LoadingRows />}
668
+ {!loading && error && (
669
+ <tr>
670
+ <td colSpan={8} className="px-3 py-12 text-center">
671
+ <div className="text-foreground mb-2 text-sm font-medium">{error}</div>
672
+ <button
673
+ onClick={() => setRefreshToken((t) => t + 1)}
674
+ className="text-primary text-sm hover:underline"
675
+ >
676
+ Try again
677
+ </button>
678
+ </td>
679
+ </tr>
680
+ )}
681
+ {!loading && !error && pages.length === 0 && (
682
+ <EmptyRow
683
+ filtersActive={filtersAreActive}
684
+ onClear={clearFilters}
685
+ onNewPage={() => onNavigate('/pages/new')}
686
+ />
687
+ )}
688
+ {!loading &&
689
+ !error &&
690
+ displayGroups.map((group) => (
691
+ <GroupBlock
692
+ key={group.key}
693
+ showHeader={grouped}
694
+ label={group.label}
695
+ color={group.color}
696
+ count={group.pages.length}
697
+ >
698
+ {group.pages.map((p) => (
699
+ <PageRow
700
+ key={p.id}
701
+ page={p}
702
+ selected={selected.has(p.id)}
703
+ resolveTag={(slug) => tagBySlug.get(slug)}
704
+ onSelect={() => toggleOne(p.id)}
705
+ onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
706
+ onDuplicate={() => void runDuplicate(p)}
707
+ onDelete={() => setRowDelete(p)}
708
+ />
709
+ ))}
710
+ </GroupBlock>
585
711
  ))}
586
- </GroupBlock>
587
- ))}
588
- </tbody>
589
- </table>
590
- </div>
712
+ </tbody>
713
+ </table>
714
+ </div>
591
715
 
592
- {/* Pagination only for the flat (ungrouped) view. */}
593
- {!grouped && (
594
- <PaginationFooter
595
- page={page}
596
- totalPages={totalPages}
597
- total={total}
598
- pageSize={PAGE_SIZE}
599
- onPage={setPage}
600
- />
716
+ {/* Pagination only for the flat (ungrouped) view. */}
717
+ {!grouped && (
718
+ <PaginationFooter
719
+ page={page}
720
+ totalPages={totalPages}
721
+ total={total}
722
+ pageSize={PAGE_SIZE}
723
+ onPage={setPage}
724
+ />
725
+ )}
726
+ </>
601
727
  )}
602
728
  </div>
603
729
 
@@ -668,6 +794,49 @@ function SortHeader({
668
794
  )
669
795
  }
670
796
 
797
+ /**
798
+ * List / Tree segmented control (`.view-toggle` in the design). The
799
+ * choice persists in localStorage so the preferred mode survives reloads.
800
+ */
801
+ function ViewModeToggle({
802
+ mode,
803
+ onChange,
804
+ }: {
805
+ mode: ViewMode
806
+ onChange: (mode: ViewMode) => void
807
+ }) {
808
+ const btn = (target: ViewMode, label: string, icon: React.ReactNode) => {
809
+ const active = mode === target
810
+ return (
811
+ <button
812
+ type="button"
813
+ aria-label={label}
814
+ title={label}
815
+ aria-pressed={active}
816
+ onClick={() => onChange(target)}
817
+ className={`flex h-[30px] w-[34px] items-center justify-center transition-colors duration-[var(--motion-fast)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
818
+ active
819
+ ? 'bg-[var(--acc-l)] text-[var(--acc)]'
820
+ : 'bg-[var(--card)] text-[var(--muted)] hover:text-[var(--acc)]'
821
+ }`}
822
+ >
823
+ {icon}
824
+ </button>
825
+ )
826
+ }
827
+ return (
828
+ <div
829
+ role="group"
830
+ aria-label="View mode"
831
+ className="flex overflow-hidden rounded-lg border border-[var(--bdr)] shadow-sm"
832
+ >
833
+ {btn('list', 'List view', <List size={15} aria-hidden />)}
834
+ <span aria-hidden className="w-px bg-[var(--bdr)]" />
835
+ {btn('tree', 'Tree view', <ListTree size={15} aria-hidden />)}
836
+ </div>
837
+ )
838
+ }
839
+
671
840
  function FilterSelect({
672
841
  label,
673
842
  value,