@actuate-media/cms-admin 0.65.0 → 0.67.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 (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -4
  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__/lib/pages-tree.test.d.ts +2 -0
  10. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  12. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  13. package/dist/__tests__/views/dashboard.test.js +7 -3
  14. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  18. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  19. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  22. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  23. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  24. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  27. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  28. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  29. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  30. package/dist/components/ui/InspectorPane.d.ts +51 -0
  31. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  32. package/dist/components/ui/InspectorPane.js +42 -0
  33. package/dist/components/ui/InspectorPane.js.map +1 -0
  34. package/dist/components/ui/RowActions.d.ts +19 -0
  35. package/dist/components/ui/RowActions.d.ts.map +1 -0
  36. package/dist/components/ui/RowActions.js +16 -0
  37. package/dist/components/ui/RowActions.js.map +1 -0
  38. package/dist/components/ui/index.d.ts +4 -0
  39. package/dist/components/ui/index.d.ts.map +1 -1
  40. package/dist/components/ui/index.js +2 -0
  41. package/dist/components/ui/index.js.map +1 -1
  42. package/dist/layout/Header.js +1 -1
  43. package/dist/layout/Header.js.map +1 -1
  44. package/dist/layout/Layout.js +8 -38
  45. package/dist/layout/Layout.js.map +1 -1
  46. package/dist/lib/page-editor-service.d.ts +11 -2
  47. package/dist/lib/page-editor-service.d.ts.map +1 -1
  48. package/dist/lib/page-editor-service.js +8 -2
  49. package/dist/lib/page-editor-service.js.map +1 -1
  50. package/dist/lib/pages-service.d.ts +9 -0
  51. package/dist/lib/pages-service.d.ts.map +1 -1
  52. package/dist/lib/pages-service.js +14 -0
  53. package/dist/lib/pages-service.js.map +1 -1
  54. package/dist/lib/pages-tree.d.ts +44 -0
  55. package/dist/lib/pages-tree.d.ts.map +1 -0
  56. package/dist/lib/pages-tree.js +106 -0
  57. package/dist/lib/pages-tree.js.map +1 -0
  58. package/dist/lib/posts-service.d.ts +11 -0
  59. package/dist/lib/posts-service.d.ts.map +1 -1
  60. package/dist/lib/posts-service.js +13 -0
  61. package/dist/lib/posts-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -0
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/Dashboard.d.ts.map +1 -1
  66. package/dist/views/Dashboard.js +40 -3
  67. package/dist/views/Dashboard.js.map +1 -1
  68. package/dist/views/MediaBrowser.d.ts.map +1 -1
  69. package/dist/views/MediaBrowser.js +55 -2
  70. package/dist/views/MediaBrowser.js.map +1 -1
  71. package/dist/views/MediaSeoAudit.d.ts +25 -0
  72. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  73. package/dist/views/MediaSeoAudit.js +97 -0
  74. package/dist/views/MediaSeoAudit.js.map +1 -0
  75. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  76. package/dist/views/Pages/PagesListView.js +145 -16
  77. package/dist/views/Pages/PagesListView.js.map +1 -1
  78. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  79. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  80. package/dist/views/Pages/PagesTreeTable.js +28 -0
  81. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  82. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  83. package/dist/views/Posts/PostsListView.js +66 -9
  84. package/dist/views/Posts/PostsListView.js.map +1 -1
  85. package/dist/views/ScriptTags.d.ts +5 -1
  86. package/dist/views/ScriptTags.d.ts.map +1 -1
  87. package/dist/views/ScriptTags.js +32 -7
  88. package/dist/views/ScriptTags.js.map +1 -1
  89. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  90. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  91. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  92. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  93. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  94. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  95. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  96. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  97. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  98. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  99. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  100. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  101. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  102. package/dist/views/seo/TechnicalTab.js +15 -0
  103. package/dist/views/seo/TechnicalTab.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/AdminRoot.tsx +7 -3
  106. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  107. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  108. package/src/__tests__/views/dashboard.test.tsx +7 -3
  109. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  110. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  111. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  112. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  113. package/src/components/ui/InspectorPane.tsx +129 -0
  114. package/src/components/ui/RowActions.tsx +50 -0
  115. package/src/components/ui/index.ts +12 -0
  116. package/src/layout/Header.tsx +2 -2
  117. package/src/layout/Layout.tsx +8 -80
  118. package/src/lib/page-editor-service.ts +16 -2
  119. package/src/lib/pages-service.ts +18 -0
  120. package/src/lib/pages-tree.ts +145 -0
  121. package/src/lib/posts-service.ts +26 -0
  122. package/src/lib/seo-service.ts +3 -0
  123. package/src/views/Dashboard.tsx +286 -214
  124. package/src/views/MediaBrowser.tsx +673 -512
  125. package/src/views/MediaSeoAudit.tsx +320 -0
  126. package/src/views/Pages/PagesListView.tsx +459 -185
  127. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  128. package/src/views/Posts/PostsListView.tsx +135 -10
  129. package/src/views/ScriptTags.tsx +204 -129
  130. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  131. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  132. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  133. package/src/views/seo/TechnicalTab.tsx +15 -0
  134. package/dist/views/ScriptTagEditor.d.ts +0 -6
  135. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  136. package/dist/views/ScriptTagEditor.js +0 -111
  137. package/dist/views/ScriptTagEditor.js.map +0 -1
  138. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -6,6 +6,10 @@ import {
6
6
  ChevronLeft,
7
7
  ChevronRight,
8
8
  ChevronUp,
9
+ Copy,
10
+ List,
11
+ ListTree,
12
+ PenLine,
9
13
  Plus,
10
14
  Search,
11
15
  Tag as TagIcon,
@@ -20,6 +24,8 @@ import {
20
24
  bulkChangePageTags,
21
25
  bulkDeletePages,
22
26
  bulkUpdatePages,
27
+ deletePage,
28
+ duplicatePage,
23
29
  fetchPagesDataset,
24
30
  groupPages,
25
31
  type BulkResult,
@@ -29,9 +35,12 @@ import {
29
35
  type PageStatus,
30
36
  type PageTag,
31
37
  } from '../../lib/pages-service.js'
38
+ import { buildPageTree, defaultExpandedIds, flattenPageTree } from '../../lib/pages-tree.js'
32
39
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
33
40
  import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
34
41
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
42
+ import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
43
+ import { PagesTreeTable } from './PagesTreeTable.js'
35
44
 
36
45
  /**
37
46
  * Pages management surface — table with a tag-chip filter bar, group-by,
@@ -51,8 +60,19 @@ interface PagesListViewProps {
51
60
 
52
61
  type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
53
62
  type Dir = 'asc' | 'desc'
63
+ type ViewMode = 'list' | 'tree'
54
64
 
55
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
+ }
56
76
 
57
77
  export function PagesListView({ onNavigate }: PagesListViewProps) {
58
78
  const [search, setSearch] = useState('')
@@ -64,12 +84,17 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
64
84
  const [dir, setDir] = useState<Dir>('asc')
65
85
  const [page, setPage] = useState(1)
66
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)
67
91
 
68
92
  const [dataset, setDataset] = useState<PagesDataset | null>(null)
69
93
  const [loading, setLoading] = useState(true)
70
94
  const [error, setError] = useState<string | null>(null)
71
95
  const [refreshToken, setRefreshToken] = useState(0)
72
96
  const [pendingDelete, setPendingDelete] = useState(false)
97
+ const [rowDelete, setRowDelete] = useState<Page | null>(null)
73
98
 
74
99
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
75
100
  useEffect(() => {
@@ -148,6 +173,49 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
148
173
 
149
174
  const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
150
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
+
151
219
  const handleSort = useCallback(
152
220
  (field: SortBy) => {
153
221
  if (sortBy === field) {
@@ -192,16 +260,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
192
260
  return next
193
261
  })
194
262
 
195
- 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))
196
271
  const toggleAllOnPage = (checked: boolean) =>
197
272
  setSelected((s) => {
198
273
  const next = new Set(s)
199
- if (checked) for (const p of pages) next.add(p.id)
200
- 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)
201
276
  return next
202
277
  })
203
278
 
204
- 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
+ )
205
283
  const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
206
284
  const blockedHomepage = selectedPages.length !== deletablePages.length
207
285
 
@@ -284,16 +362,71 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
284
362
  afterBulk()
285
363
  }
286
364
 
365
+ // ─── Row actions ───────────────────────────────────────────────────
366
+
367
+ const runDuplicate = async (p: Page) => {
368
+ const promise = duplicatePage(p.id).then((r) => {
369
+ if (r.error) throw new Error(r.error)
370
+ return r
371
+ })
372
+ toast.promise(promise, {
373
+ loading: `Duplicating "${p.title}"…`,
374
+ success: `"${p.title}" duplicated`,
375
+ error: (e: Error) => e.message || 'Duplicate failed',
376
+ })
377
+ try {
378
+ await promise
379
+ setRefreshToken((t) => t + 1)
380
+ } catch {
381
+ /* surfaced via toast.promise */
382
+ }
383
+ }
384
+
385
+ const runRowDelete = async (p: Page) => {
386
+ const promise = deletePage(p.id).then((r) => {
387
+ if (r.error) throw new Error(r.error)
388
+ })
389
+ toast.promise(promise, {
390
+ loading: `Deleting "${p.title}"…`,
391
+ success: `"${p.title}" moved to trash`,
392
+ error: (e: Error) => e.message || 'Delete failed',
393
+ })
394
+ try {
395
+ await promise
396
+ setSelected((s) => {
397
+ const next = new Set(s)
398
+ next.delete(p.id)
399
+ return next
400
+ })
401
+ setRefreshToken((t) => t + 1)
402
+ } catch {
403
+ /* surfaced via toast.promise */
404
+ }
405
+ }
406
+
287
407
  // ─── Render ────────────────────────────────────────────────────────
288
408
 
289
409
  return (
290
410
  <div className="flex h-full flex-col gap-4 p-4 sm:p-6">
291
- {/* Header */}
292
- <div className="flex flex-col gap-1">
293
- <h1 className="text-foreground">Pages</h1>
294
- <p className="text-muted-foreground text-sm">
295
- {grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
296
- </p>
411
+ {/* Header — the "+ New page" primary action sits on the right, per
412
+ the design's `.page-hdr` / `.hdr-r` layout. */}
413
+ <div className="flex flex-wrap items-start justify-between gap-3">
414
+ <div className="flex flex-col gap-1">
415
+ <h1 className="text-foreground">Pages</h1>
416
+ <p className="text-muted-foreground text-sm">
417
+ {treeMode
418
+ ? `${grandTotal} page${grandTotal === 1 ? '' : 's'} total`
419
+ : `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
420
+ </p>
421
+ </div>
422
+ <button
423
+ type="button"
424
+ onClick={() => onNavigate('/pages/new')}
425
+ className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
426
+ >
427
+ <Plus className="h-3.5 w-3.5" aria-hidden />
428
+ New page
429
+ </button>
297
430
  </div>
298
431
 
299
432
  {/* Controls row */}
@@ -313,17 +446,21 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
313
446
  />
314
447
  </div>
315
448
 
316
- <FilterSelect
317
- label="Group by"
318
- value={groupBy}
319
- onChange={(v) => setGroupBy(v as PageGroupBy)}
320
- options={[
321
- { value: 'none', label: 'None' },
322
- { value: 'tag', label: 'Tag' },
323
- { value: 'status', label: 'Status' },
324
- { value: 'author', label: 'Author' },
325
- ]}
326
- />
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
+ )}
327
464
 
328
465
  <FilterSelect
329
466
  label="Status"
@@ -340,45 +477,54 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
340
477
  ]}
341
478
  />
342
479
 
480
+ <ViewModeToggle mode={viewMode} onChange={changeViewMode} />
481
+
343
482
  <span className="text-muted-foreground ml-auto text-sm tabular-nums">
344
- {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}`}
345
488
  </span>
346
489
  </div>
347
490
 
348
- {/* Tag filter bar */}
349
- <div className="flex flex-wrap items-center gap-2">
350
- <span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
351
- Tags
352
- </span>
353
- {tags.map((t) => (
354
- <PageTagChip
355
- key={t.slug}
356
- name={t.name}
357
- color={t.color}
358
- count={t.pageCount}
359
- selected={selectedTags.has(t.slug)}
360
- onToggle={() => toggleTag(t.slug)}
361
- />
362
- ))}
363
- {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
+ )}
364
518
  <button
365
519
  type="button"
366
- onClick={() => setSelectedTags(new Set())}
367
- 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"
368
522
  >
369
- <X className="h-3 w-3" aria-hidden />
370
- Clear tags
523
+ <TagIcon className="h-3 w-3" aria-hidden />
524
+ Manage tags
371
525
  </button>
372
- )}
373
- <button
374
- type="button"
375
- onClick={() => onNavigate('/pages/tags')}
376
- className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
377
- >
378
- <TagIcon className="h-3 w-3" aria-hidden />
379
- Manage tags
380
- </button>
381
- </div>
526
+ </div>
527
+ )}
382
528
 
383
529
  {/* Bulk action bar */}
384
530
  {selected.size > 0 && (
@@ -398,144 +544,202 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
398
544
 
399
545
  {/* Table card */}
400
546
  <div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
401
- {/* 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
402
569
  width and the table scrolls sideways instead of crushing content. */}
403
- <div className="overflow-x-auto">
404
- <table className="w-full min-w-[800px] table-fixed" role="table" aria-label="Pages">
405
- <colgroup>
406
- <col style={{ width: '40px' }} />
407
- <col />
408
- <col style={{ width: '200px' }} />
409
- <col style={{ width: '170px' }} />
410
- <col style={{ width: '120px' }} />
411
- <col style={{ width: '130px' }} />
412
- <col style={{ width: '80px' }} />
413
- </colgroup>
414
- <thead className="border-border bg-muted/40 border-b">
415
- <tr className="text-muted-foreground text-xs">
416
- <th scope="col" className="px-3 py-2.5 text-left">
417
- <input
418
- type="checkbox"
419
- aria-label="Select all pages on this page"
420
- checked={allOnPageSelected}
421
- onChange={(e) => toggleAllOnPage(e.target.checked)}
422
- className="border-border accent-primary rounded"
423
- />
424
- </th>
425
- <th scope="col" aria-sort={ariaSortFor('title')} className="px-3 py-2.5 text-left">
426
- <SortHeader
427
- label="Title"
428
- field="title"
429
- current={sortBy}
430
- dir={dir}
431
- onSort={handleSort}
432
- />
433
- </th>
434
- <th scope="col" className="px-3 py-2.5 text-left">
435
- <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
436
- </th>
437
- <th scope="col" aria-sort={ariaSortFor('author')} className="px-3 py-2.5 text-left">
438
- <SortHeader
439
- label="Author"
440
- field="author"
441
- current={sortBy}
442
- dir={dir}
443
- onSort={handleSort}
444
- />
445
- </th>
446
- <th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
447
- <SortHeader
448
- label="Status"
449
- field="status"
450
- current={sortBy}
451
- dir={dir}
452
- onSort={handleSort}
453
- />
454
- </th>
455
- <th
456
- scope="col"
457
- aria-sort={ariaSortFor('modified')}
458
- className="px-3 py-2.5 text-left"
459
- >
460
- <SortHeader
461
- label="Last Modified"
462
- field="modified"
463
- current={sortBy}
464
- dir={dir}
465
- onSort={handleSort}
466
- />
467
- </th>
468
- <th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
469
- <SortHeader
470
- label="SEO"
471
- field="seo"
472
- current={sortBy}
473
- dir={dir}
474
- onSort={handleSort}
475
- />
476
- </th>
477
- </tr>
478
- </thead>
479
- <tbody>
480
- {loading && pages.length === 0 && <LoadingRows />}
481
- {!loading && error && (
482
- <tr>
483
- <td colSpan={7} className="px-3 py-12 text-center">
484
- <div className="text-foreground mb-2 text-sm font-medium">{error}</div>
485
- <button
486
- onClick={() => setRefreshToken((t) => t + 1)}
487
- 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"
488
613
  >
489
- Try again
490
- </button>
491
- </td>
492
- </tr>
493
- )}
494
- {!loading && !error && pages.length === 0 && (
495
- <EmptyRow
496
- filtersActive={filtersAreActive}
497
- onClear={clearFilters}
498
- onNewPage={() => onNavigate('/pages/new')}
499
- />
500
- )}
501
- {!loading &&
502
- !error &&
503
- displayGroups.map((group) => (
504
- <GroupBlock
505
- key={group.key}
506
- showHeader={grouped}
507
- label={group.label}
508
- color={group.color}
509
- count={group.pages.length}
510
- >
511
- {group.pages.map((p) => (
512
- <PageRow
513
- key={p.id}
514
- page={p}
515
- selected={selected.has(p.id)}
516
- resolveTag={(slug) => tagBySlug.get(slug)}
517
- onSelect={() => toggleOne(p.id)}
518
- onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
614
+ <SortHeader
615
+ label="Author"
616
+ field="author"
617
+ current={sortBy}
618
+ dir={dir}
619
+ onSort={handleSort}
519
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>
520
711
  ))}
521
- </GroupBlock>
522
- ))}
523
- </tbody>
524
- </table>
525
- </div>
712
+ </tbody>
713
+ </table>
714
+ </div>
526
715
 
527
- {/* Pagination only for the flat (ungrouped) view. */}
528
- {!grouped && (
529
- <PaginationFooter
530
- page={page}
531
- totalPages={totalPages}
532
- total={total}
533
- pageSize={PAGE_SIZE}
534
- onPage={setPage}
535
- />
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
+ </>
536
727
  )}
537
728
  </div>
538
729
 
730
+ <ConfirmDialog
731
+ open={rowDelete !== null}
732
+ title={`Delete "${rowDelete?.title ?? ''}"?`}
733
+ description="The page moves to trash and can be restored for 30 days."
734
+ confirmLabel="Delete"
735
+ destructive
736
+ onClose={() => setRowDelete(null)}
737
+ onConfirm={() => {
738
+ if (rowDelete) void runRowDelete(rowDelete)
739
+ setRowDelete(null)
740
+ }}
741
+ />
742
+
539
743
  <ConfirmDialog
540
744
  open={pendingDelete}
541
745
  title={`Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`}
@@ -590,6 +794,49 @@ function SortHeader({
590
794
  )
591
795
  }
592
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
+
593
840
  function FilterSelect({
594
841
  label,
595
842
  value,
@@ -636,7 +883,7 @@ function GroupBlock({
636
883
  <>
637
884
  {showHeader && (
638
885
  <tr className="bg-muted/30 border-border border-b">
639
- <td colSpan={7} className="px-3 py-2">
886
+ <td colSpan={8} className="px-3 py-2">
640
887
  <span className="text-foreground inline-flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
641
888
  {color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
642
889
  {label}
@@ -779,12 +1026,16 @@ function PageRow({
779
1026
  resolveTag,
780
1027
  onSelect,
781
1028
  onOpen,
1029
+ onDuplicate,
1030
+ onDelete,
782
1031
  }: {
783
1032
  page: Page
784
1033
  selected: boolean
785
1034
  resolveTag: (slug: string) => { name: string; color: string | null } | undefined
786
1035
  onSelect: () => void
787
1036
  onOpen: () => void
1037
+ onDuplicate: () => void
1038
+ onDelete: () => void
788
1039
  }) {
789
1040
  return (
790
1041
  <tr
@@ -835,6 +1086,29 @@ function PageRow({
835
1086
  <td className="px-3 py-3">
836
1087
  <SeoScoreIndicator score={page.seoScore} />
837
1088
  </td>
1089
+ <td className="px-3 py-3">
1090
+ <RowActions>
1091
+ <RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
1092
+ <PenLine size={13} aria-hidden />
1093
+ </RowActionButton>
1094
+ <RowActionButton
1095
+ aria-label={`Duplicate "${page.title}"`}
1096
+ title="Duplicate"
1097
+ onClick={onDuplicate}
1098
+ >
1099
+ <Copy size={12} aria-hidden />
1100
+ </RowActionButton>
1101
+ <RowActionButton
1102
+ aria-label={`Delete "${page.title}"`}
1103
+ title={page.isHomepage ? 'The homepage cannot be deleted' : 'Delete'}
1104
+ danger
1105
+ disabled={page.isHomepage}
1106
+ onClick={onDelete}
1107
+ >
1108
+ <Trash2 size={12} aria-hidden />
1109
+ </RowActionButton>
1110
+ </RowActions>
1111
+ </td>
838
1112
  </tr>
839
1113
  )
840
1114
  }
@@ -844,7 +1118,7 @@ function LoadingRows() {
844
1118
  <>
845
1119
  {Array.from({ length: 8 }).map((_, i) => (
846
1120
  <tr key={i} className="border-border border-b last:border-b-0">
847
- {Array.from({ length: 7 }).map((__, j) => (
1121
+ {Array.from({ length: 8 }).map((__, j) => (
848
1122
  <td key={j} className="px-3 py-3">
849
1123
  <div className="bg-muted h-4 w-full animate-pulse rounded" />
850
1124
  </td>
@@ -866,7 +1140,7 @@ function EmptyRow({
866
1140
  }) {
867
1141
  return (
868
1142
  <tr>
869
- <td colSpan={7} className="px-3 py-16 text-center">
1143
+ <td colSpan={8} className="px-3 py-16 text-center">
870
1144
  <div className="text-foreground mb-2 text-sm font-medium">
871
1145
  {filtersActive ? 'No pages match your filters' : 'No pages yet'}
872
1146
  </div>