@actuate-media/cms-admin 0.69.1 → 0.70.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 (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-tree.test.js +40 -1
  6. package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
  7. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
  10. package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
  11. package/dist/__tests__/views/page-templates.render.test.js +21 -0
  12. package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
  13. package/dist/actuate-admin.css +1 -1
  14. package/dist/components/TipTapEditor.d.ts.map +1 -1
  15. package/dist/components/TipTapEditor.js +11 -9
  16. package/dist/components/TipTapEditor.js.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lib/pages-service.d.ts +7 -0
  22. package/dist/lib/pages-service.d.ts.map +1 -1
  23. package/dist/lib/pages-service.js +11 -0
  24. package/dist/lib/pages-service.js.map +1 -1
  25. package/dist/lib/pages-tree.d.ts +14 -0
  26. package/dist/lib/pages-tree.d.ts.map +1 -1
  27. package/dist/lib/pages-tree.js +51 -0
  28. package/dist/lib/pages-tree.js.map +1 -1
  29. package/dist/views/FormEditor.js +5 -7
  30. package/dist/views/FormEditor.js.map +1 -1
  31. package/dist/views/HiddenLoginNotFound.d.ts +6 -0
  32. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
  33. package/dist/views/HiddenLoginNotFound.js +6 -0
  34. package/dist/views/HiddenLoginNotFound.js.map +1 -1
  35. package/dist/views/MediaBrowser.d.ts.map +1 -1
  36. package/dist/views/MediaBrowser.js +2 -1
  37. package/dist/views/MediaBrowser.js.map +1 -1
  38. package/dist/views/PageEditor.d.ts +4 -0
  39. package/dist/views/PageEditor.d.ts.map +1 -1
  40. package/dist/views/PageEditor.js +4 -0
  41. package/dist/views/PageEditor.js.map +1 -1
  42. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  43. package/dist/views/Pages/PagesListView.js +40 -5
  44. package/dist/views/Pages/PagesListView.js.map +1 -1
  45. package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
  46. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  47. package/dist/views/Pages/PagesTreeTable.js +40 -9
  48. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  49. package/dist/views/Pages.d.ts +4 -0
  50. package/dist/views/Pages.d.ts.map +1 -1
  51. package/dist/views/Pages.js +4 -0
  52. package/dist/views/Pages.js.map +1 -1
  53. package/dist/views/Redirects.d.ts +4 -0
  54. package/dist/views/Redirects.d.ts.map +1 -1
  55. package/dist/views/Redirects.js +4 -0
  56. package/dist/views/Redirects.js.map +1 -1
  57. package/dist/views/ScriptTags.d.ts.map +1 -1
  58. package/dist/views/ScriptTags.js +2 -1
  59. package/dist/views/ScriptTags.js.map +1 -1
  60. package/dist/views/page-builder/PageBuilder.d.ts +3 -1
  61. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  62. package/dist/views/page-builder/PageBuilder.js +24 -2
  63. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  64. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  65. package/dist/views/page-builder/PageTemplates.js +30 -4
  66. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/AdminRoot.tsx +11 -1
  69. package/src/__tests__/lib/pages-tree.test.ts +49 -0
  70. package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
  71. package/src/__tests__/views/page-templates.render.test.tsx +40 -0
  72. package/src/components/TipTapEditor.tsx +11 -9
  73. package/src/index.ts +3 -0
  74. package/src/lib/pages-service.ts +15 -0
  75. package/src/lib/pages-tree.ts +54 -0
  76. package/src/views/FormEditor.tsx +10 -10
  77. package/src/views/HiddenLoginNotFound.tsx +6 -0
  78. package/src/views/MediaBrowser.tsx +8 -9
  79. package/src/views/PageEditor.tsx +4 -0
  80. package/src/views/Pages/PagesListView.tsx +54 -9
  81. package/src/views/Pages/PagesTreeTable.tsx +203 -111
  82. package/src/views/Pages.tsx +4 -0
  83. package/src/views/Redirects.tsx +4 -0
  84. package/src/views/ScriptTags.tsx +12 -8
  85. package/src/views/page-builder/PageBuilder.tsx +30 -2
  86. package/src/views/page-builder/PageTemplates.tsx +56 -15
@@ -28,6 +28,7 @@ import {
28
28
  duplicatePage,
29
29
  fetchPagesDataset,
30
30
  groupPages,
31
+ movePage,
31
32
  type BulkResult,
32
33
  type Page,
33
34
  type PageGroupBy,
@@ -35,9 +36,15 @@ import {
35
36
  type PageStatus,
36
37
  type PageTag,
37
38
  } from '../../lib/pages-service.js'
38
- import { buildPageTree, defaultExpandedIds, flattenPageTree } from '../../lib/pages-tree.js'
39
+ import {
40
+ buildPageTree,
41
+ checkReparent,
42
+ defaultExpandedIds,
43
+ flattenPageTree,
44
+ } from '../../lib/pages-tree.js'
39
45
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
40
46
  import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
47
+ import { Button } from '../../components/ui/Button.js'
41
48
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
42
49
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
43
50
  import { PagesTreeTable } from './PagesTreeTable.js'
@@ -382,6 +389,38 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
382
389
  }
383
390
  }
384
391
 
392
+ /** Drag-reparent from the tree view: `sourceId` becomes a child of `targetId`. */
393
+ const runReparent = async (sourceId: string, targetId: string) => {
394
+ const source = allPages.find((p) => p.id === sourceId)
395
+ const target = allPages.find((p) => p.id === targetId)
396
+ if (!source || !target) return
397
+ const check = checkReparent(treeRoots, sourceId, targetId)
398
+ if (!check.ok) {
399
+ toast.error(check.reason)
400
+ return
401
+ }
402
+ const promise = movePage(sourceId, targetId).then((r) => {
403
+ if (r.error) throw new Error(r.error)
404
+ })
405
+ toast.promise(promise, {
406
+ loading: `Moving "${source.title}"…`,
407
+ success: `"${source.title}" moved under "${target.title}"`,
408
+ error: (e: Error) => e.message || 'Move failed',
409
+ })
410
+ try {
411
+ await promise
412
+ // Make sure the new parent is expanded so the moved row stays visible.
413
+ setExpandedOverride((prev) => {
414
+ const next = new Set(prev ?? defaultExpanded)
415
+ next.add(targetId)
416
+ return next
417
+ })
418
+ setRefreshToken((t) => t + 1)
419
+ } catch {
420
+ /* surfaced via toast.promise */
421
+ }
422
+ }
423
+
385
424
  const runRowDelete = async (p: Page) => {
386
425
  const promise = deletePage(p.id).then((r) => {
387
426
  if (r.error) throw new Error(r.error)
@@ -419,14 +458,16 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
419
458
  : `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
420
459
  </p>
421
460
  </div>
422
- <button
461
+ <Button
423
462
  type="button"
463
+ variant="primary"
464
+ size="sm"
465
+ className="shadow-sm"
424
466
  onClick={() => onNavigate('/pages/new')}
425
- className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
467
+ leftIcon={<Plus size={16} aria-hidden />}
426
468
  >
427
- <Plus className="h-3.5 w-3.5" aria-hidden />
428
469
  New page
429
- </button>
470
+ </Button>
430
471
  </div>
431
472
 
432
473
  {/* Controls row */}
@@ -559,6 +600,8 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
559
600
  onAddSubPage={(p) => onNavigate(`/pages/new?parent=${encodeURIComponent(p.id)}`)}
560
601
  onDuplicate={(p) => void runDuplicate(p)}
561
602
  onDelete={(p) => setRowDelete(p)}
603
+ onReparent={(sourceId, targetId) => void runReparent(sourceId, targetId)}
604
+ canReparent={(sourceId, targetId) => checkReparent(treeRoots, sourceId, targetId).ok}
562
605
  onRetry={() => setRefreshToken((t) => t + 1)}
563
606
  onClearFilters={clearFilters}
564
607
  onNewPage={() => onNavigate('/pages/new')}
@@ -1154,14 +1197,16 @@ function EmptyRow({
1154
1197
  Clear filters
1155
1198
  </button>
1156
1199
  ) : (
1157
- <button
1200
+ <Button
1158
1201
  type="button"
1202
+ variant="primary"
1203
+ size="sm"
1204
+ className="shadow-sm"
1159
1205
  onClick={onNewPage}
1160
- className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors"
1206
+ leftIcon={<Plus size={16} aria-hidden />}
1161
1207
  >
1162
- <Plus className="h-3.5 w-3.5" aria-hidden />
1163
1208
  New page
1164
- </button>
1209
+ </Button>
1165
1210
  )}
1166
1211
  </td>
1167
1212
  </tr>
@@ -1,9 +1,23 @@
1
1
  'use client'
2
2
 
3
- import { ChevronDown, Copy, PenLine, Plus, Trash2 } from 'lucide-react'
3
+ import { useState } from 'react'
4
+ import {
5
+ DndContext,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ pointerWithin,
9
+ useDraggable,
10
+ useDroppable,
11
+ useSensor,
12
+ useSensors,
13
+ type DragEndEvent,
14
+ type DragStartEvent,
15
+ } from '@dnd-kit/core'
16
+ import { ChevronDown, Copy, GripVertical, PenLine, Plus, Trash2 } from 'lucide-react'
4
17
  import type { Page } from '../../lib/pages-service.js'
5
18
  import type { PageTreeRow } from '../../lib/pages-tree.js'
6
19
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
20
+ import { Button } from '../../components/ui/Button.js'
7
21
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
8
22
 
9
23
  /**
@@ -16,6 +30,12 @@ import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
16
30
  * renders at depth 0 and expand state is ignored — the caller passes
17
31
  * pre-flattened rows, this component just skips the leaf spacers' tree
18
32
  * semantics.
33
+ *
34
+ * Drag-reparent: rows can be dragged (grip handle) and dropped onto
35
+ * another row to become its child. There is intentionally no "drop to
36
+ * make top-level" zone — with `parentPageId` cleared, the tree falls back
37
+ * to path-prefix derivation, which would silently re-nest most pages, so
38
+ * un-nesting stays an editor operation. Disabled while flattened.
19
39
  */
20
40
 
21
41
  interface PagesTreeTableProps {
@@ -33,6 +53,10 @@ interface PagesTreeTableProps {
33
53
  onAddSubPage: (page: Page) => void
34
54
  onDuplicate: (page: Page) => void
35
55
  onDelete: (page: Page) => void
56
+ /** Drop handler: `source` becomes a child of `targetParent`. */
57
+ onReparent: (sourceId: string, targetParentId: string) => void
58
+ /** Validity gate for a drop (cycle/self/no-op checks live in the caller). */
59
+ canReparent: (sourceId: string, targetParentId: string) => boolean
36
60
  onRetry: () => void
37
61
  onClearFilters: () => void
38
62
  onNewPage: () => void
@@ -55,125 +79,157 @@ export function PagesTreeTable({
55
79
  onAddSubPage,
56
80
  onDuplicate,
57
81
  onDelete,
82
+ onReparent,
83
+ canReparent,
58
84
  onRetry,
59
85
  onClearFilters,
60
86
  onNewPage,
61
87
  }: PagesTreeTableProps) {
62
88
  const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.page.id))
89
+ const [draggingId, setDraggingId] = useState<string | null>(null)
90
+
91
+ // A small activation distance keeps plain clicks (title, checkbox,
92
+ // chevron) from starting a drag.
93
+ const sensors = useSensors(
94
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
95
+ useSensor(KeyboardSensor),
96
+ )
97
+
98
+ const handleDragStart = (event: DragStartEvent) => {
99
+ setDraggingId(String(event.active.id))
100
+ }
101
+
102
+ const handleDragEnd = (event: DragEndEvent) => {
103
+ setDraggingId(null)
104
+ const sourceId = String(event.active.id)
105
+ const targetId = event.over ? String(event.over.id) : null
106
+ if (!targetId || targetId === sourceId) return
107
+ if (!canReparent(sourceId, targetId)) return
108
+ onReparent(sourceId, targetId)
109
+ }
63
110
 
64
111
  return (
65
- <div className="overflow-x-auto">
66
- <table className="w-full min-w-[800px]" role="table" aria-label="Pages tree">
67
- <colgroup>
68
- <col style={{ width: '40px' }} />
69
- <col />
70
- <col style={{ width: '130px' }} />
71
- <col style={{ width: '150px' }} />
72
- <col style={{ width: '120px' }} />
73
- <col style={{ width: '115px' }} />
74
- <col style={{ width: '70px' }} />
75
- <col style={{ width: '170px' }} />
76
- </colgroup>
77
- <thead className="border-b border-[var(--bdr)]">
78
- <tr>
79
- <th scope="col" className="px-3 py-2.5 text-left">
80
- <input
81
- type="checkbox"
82
- aria-label="Select all visible pages"
83
- checked={allSelected}
84
- onChange={(e) => onToggleAll(e.target.checked)}
85
- className="accent-primary rounded border-[var(--bdr)]"
86
- />
87
- </th>
88
- <th scope="col" className={`${TH_CLASS} min-w-[280px]`}>
89
- Title
90
- </th>
91
- <th scope="col" className={TH_CLASS}>
92
- Template
93
- </th>
94
- <th scope="col" className={TH_CLASS}>
95
- Author
96
- </th>
97
- <th scope="col" className={TH_CLASS}>
98
- Status
99
- </th>
100
- <th scope="col" className={TH_CLASS}>
101
- Last Modified
102
- </th>
103
- <th scope="col" className={TH_CLASS}>
104
- SEO
105
- </th>
106
- <th scope="col" className="px-3 py-2.5 text-right">
107
- <span className="sr-only">Actions</span>
108
- </th>
109
- </tr>
110
- </thead>
111
- <tbody>
112
- {loading && rows.length === 0 && <TreeLoadingRows />}
113
- {!loading && error && (
114
- <tr>
115
- <td colSpan={8} className="px-3 py-12 text-center">
116
- <div className="mb-2 text-sm font-medium text-[var(--txt)]">{error}</div>
117
- <button
118
- onClick={onRetry}
119
- className="text-primary rounded-sm text-sm hover:underline focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
120
- >
121
- Try again
122
- </button>
123
- </td>
124
- </tr>
125
- )}
126
- {!loading && !error && rows.length === 0 && (
112
+ <DndContext
113
+ sensors={sensors}
114
+ collisionDetection={pointerWithin}
115
+ onDragStart={handleDragStart}
116
+ onDragEnd={handleDragEnd}
117
+ onDragCancel={() => setDraggingId(null)}
118
+ >
119
+ <div className="overflow-x-auto">
120
+ <table className="w-full min-w-[800px]" role="table" aria-label="Pages tree">
121
+ <colgroup>
122
+ <col style={{ width: '40px' }} />
123
+ <col />
124
+ <col style={{ width: '130px' }} />
125
+ <col style={{ width: '150px' }} />
126
+ <col style={{ width: '120px' }} />
127
+ <col style={{ width: '115px' }} />
128
+ <col style={{ width: '70px' }} />
129
+ <col style={{ width: '170px' }} />
130
+ </colgroup>
131
+ <thead className="border-b border-[var(--bdr)]">
127
132
  <tr>
128
- <td colSpan={8} className="px-3 py-16 text-center">
129
- <div className="mb-2 text-sm font-medium text-[var(--txt)]">
130
- {flattened ? 'No pages match your filters' : 'No pages yet'}
131
- </div>
132
- <div className="mb-4 text-sm text-[var(--sub)]">
133
- {flattened
134
- ? 'Try clearing the search or status filter above.'
135
- : 'Create your first page to get started.'}
136
- </div>
137
- {flattened ? (
138
- <button
139
- type="button"
140
- onClick={onClearFilters}
141
- className="text-primary rounded-sm text-sm hover:underline focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
142
- >
143
- Clear filters
144
- </button>
145
- ) : (
146
- <button
147
- type="button"
148
- onClick={onNewPage}
149
- className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
150
- >
151
- <Plus className="h-3.5 w-3.5" aria-hidden />
152
- New page
153
- </button>
154
- )}
155
- </td>
133
+ <th scope="col" className="px-3 py-2.5 text-left">
134
+ <input
135
+ type="checkbox"
136
+ aria-label="Select all visible pages"
137
+ checked={allSelected}
138
+ onChange={(e) => onToggleAll(e.target.checked)}
139
+ className="accent-primary rounded border-[var(--bdr)]"
140
+ />
141
+ </th>
142
+ <th scope="col" className={`${TH_CLASS} min-w-[280px]`}>
143
+ Title
144
+ </th>
145
+ <th scope="col" className={TH_CLASS}>
146
+ Template
147
+ </th>
148
+ <th scope="col" className={TH_CLASS}>
149
+ Author
150
+ </th>
151
+ <th scope="col" className={TH_CLASS}>
152
+ Status
153
+ </th>
154
+ <th scope="col" className={TH_CLASS}>
155
+ Last Modified
156
+ </th>
157
+ <th scope="col" className={TH_CLASS}>
158
+ SEO
159
+ </th>
160
+ <th scope="col" className="px-3 py-2.5 text-right">
161
+ <span className="sr-only">Actions</span>
162
+ </th>
156
163
  </tr>
157
- )}
158
- {!loading &&
159
- !error &&
160
- rows.map((row) => (
161
- <TreeRow
162
- key={row.page.id}
163
- row={row}
164
- isSelected={selected.has(row.page.id)}
165
- isExpanded={expanded.has(row.page.id)}
166
- onToggleExpand={() => onToggleExpand(row.page.id)}
167
- onToggleSelect={() => onToggleSelect(row.page.id)}
168
- onOpen={() => onOpen(row.page)}
169
- onAddSubPage={() => onAddSubPage(row.page)}
170
- onDuplicate={() => onDuplicate(row.page)}
171
- onDelete={() => onDelete(row.page)}
172
- />
173
- ))}
174
- </tbody>
175
- </table>
176
- </div>
164
+ </thead>
165
+ <tbody>
166
+ {loading && rows.length === 0 && <TreeLoadingRows />}
167
+ {!loading && error && (
168
+ <tr>
169
+ <td colSpan={8} className="px-3 py-12 text-center">
170
+ <div className="mb-2 text-sm font-medium text-[var(--txt)]">{error}</div>
171
+ <Button variant="link" size="md" onClick={onRetry}>
172
+ Try again
173
+ </Button>
174
+ </td>
175
+ </tr>
176
+ )}
177
+ {!loading && !error && rows.length === 0 && (
178
+ <tr>
179
+ <td colSpan={8} className="px-3 py-16 text-center">
180
+ <div className="mb-2 text-sm font-medium text-[var(--txt)]">
181
+ {flattened ? 'No pages match your filters' : 'No pages yet'}
182
+ </div>
183
+ <div className="mb-4 text-sm text-[var(--sub)]">
184
+ {flattened
185
+ ? 'Try clearing the search or status filter above.'
186
+ : 'Create your first page to get started.'}
187
+ </div>
188
+ {flattened ? (
189
+ <Button type="button" variant="link" size="md" onClick={onClearFilters}>
190
+ Clear filters
191
+ </Button>
192
+ ) : (
193
+ <Button
194
+ type="button"
195
+ variant="primary"
196
+ size="sm"
197
+ className="shadow-sm"
198
+ onClick={onNewPage}
199
+ leftIcon={<Plus size={16} aria-hidden />}
200
+ >
201
+ New page
202
+ </Button>
203
+ )}
204
+ </td>
205
+ </tr>
206
+ )}
207
+ {!loading &&
208
+ !error &&
209
+ rows.map((row) => (
210
+ <TreeRow
211
+ key={row.page.id}
212
+ row={row}
213
+ isSelected={selected.has(row.page.id)}
214
+ isExpanded={expanded.has(row.page.id)}
215
+ draggable={!flattened}
216
+ isDropTarget={
217
+ draggingId !== null &&
218
+ draggingId !== row.page.id &&
219
+ canReparent(draggingId, row.page.id)
220
+ }
221
+ onToggleExpand={() => onToggleExpand(row.page.id)}
222
+ onToggleSelect={() => onToggleSelect(row.page.id)}
223
+ onOpen={() => onOpen(row.page)}
224
+ onAddSubPage={() => onAddSubPage(row.page)}
225
+ onDuplicate={() => onDuplicate(row.page)}
226
+ onDelete={() => onDelete(row.page)}
227
+ />
228
+ ))}
229
+ </tbody>
230
+ </table>
231
+ </div>
232
+ </DndContext>
177
233
  )
178
234
  }
179
235
 
@@ -181,6 +237,8 @@ function TreeRow({
181
237
  row,
182
238
  isSelected,
183
239
  isExpanded,
240
+ draggable,
241
+ isDropTarget,
184
242
  onToggleExpand,
185
243
  onToggleSelect,
186
244
  onOpen,
@@ -191,6 +249,9 @@ function TreeRow({
191
249
  row: PageTreeRow
192
250
  isSelected: boolean
193
251
  isExpanded: boolean
252
+ draggable: boolean
253
+ /** true while another row is being dragged and this row is a valid parent */
254
+ isDropTarget: boolean
194
255
  onToggleExpand: () => void
195
256
  onToggleSelect: () => void
196
257
  onOpen: () => void
@@ -199,10 +260,28 @@ function TreeRow({
199
260
  onDelete: () => void
200
261
  }) {
201
262
  const { page, depth, hasChildren } = row
263
+ const {
264
+ attributes,
265
+ listeners,
266
+ setNodeRef: setDragRef,
267
+ isDragging,
268
+ } = useDraggable({ id: page.id, disabled: !draggable })
269
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
270
+ id: page.id,
271
+ disabled: !draggable || !isDropTarget,
272
+ })
273
+
202
274
  return (
203
275
  <tr
276
+ ref={setDropRef}
204
277
  className={`group h-14 border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] ${
205
278
  isSelected ? 'bg-[var(--acc-l)]' : ''
279
+ } ${isDragging ? 'opacity-40' : ''} ${
280
+ isDropTarget && isOver
281
+ ? 'bg-[var(--acc-l)] shadow-[inset_0_0_0_2px_var(--acc)]'
282
+ : isDropTarget
283
+ ? 'shadow-[inset_0_0_0_1px_var(--acc-m)]'
284
+ : ''
206
285
  }`}
207
286
  >
208
287
  <td className="px-3 align-middle">
@@ -218,6 +297,19 @@ function TreeRow({
218
297
  {/* Indentation is data-driven (24px per depth level), so it must be
219
298
  an inline style rather than a class. */}
220
299
  <div className="flex items-center" style={{ paddingLeft: depth * 24 }}>
300
+ {draggable && (
301
+ <button
302
+ type="button"
303
+ ref={setDragRef}
304
+ {...attributes}
305
+ {...listeners}
306
+ aria-label={`Move "${page.title}" — drag onto another page to nest it there`}
307
+ title="Drag to move under another page"
308
+ className="mr-1 flex h-5 w-5 shrink-0 cursor-grab items-center justify-center rounded-[5px] text-[var(--muted)] opacity-0 transition-opacity duration-[var(--motion-fast)] group-hover:opacity-100 hover:bg-[var(--acc-l)] hover:text-[var(--acc)] focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none active:cursor-grabbing"
309
+ >
310
+ <GripVertical size={13} aria-hidden />
311
+ </button>
312
+ )}
221
313
  {hasChildren ? (
222
314
  <button
223
315
  type="button"
@@ -97,6 +97,10 @@ function buildApiUrl(folderSel: FolderSelection): string {
97
97
  return `${base}&folderId=${folderSel.folderId}`
98
98
  }
99
99
 
100
+ /**
101
+ * @deprecated Superseded by `PagesListView` (`views/Pages/PagesListView.tsx`),
102
+ * which `AdminRoot` routes to; will be removed in the next major.
103
+ */
100
104
  export function Pages({ onNavigate }: PagesProps) {
101
105
  const [folderSel, setFolderSel] = useState<FolderSelection>({ type: 'smart', smart: 'all' })
102
106
  const [sidebarOpen, setSidebarOpen] = useState(true)
@@ -26,6 +26,10 @@ export interface RedirectsProps {
26
26
  onNavigate?: (path: string) => void
27
27
  }
28
28
 
29
+ /**
30
+ * @deprecated Superseded by `RedirectsTab` (`views/seo/RedirectsTab.tsx`)
31
+ * inside the SEO view; will be removed in the next major.
32
+ */
29
33
  export function Redirects({ onNavigate }: RedirectsProps) {
30
34
  const { data, loading, error, refetch } = useApiData<any[]>('/redirects')
31
35
  const [showAddDialog, setShowAddDialog] = useState(false)
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react'
4
4
  import { Code2, Plus, Loader2, AlertTriangle } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { EnabledSwitch } from '../components/EnabledSwitch.js'
7
+ import { Button } from '../components/ui/Button.js'
7
8
  import { StatPill } from '../components/ui/StatPill.js'
8
9
  import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
9
10
  import {
@@ -168,14 +169,15 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
168
169
  </div>
169
170
  )}
170
171
  </div>
171
- <button
172
+ <Button
172
173
  type="button"
174
+ variant="primary"
175
+ size="md"
173
176
  onClick={() => setSelection({ mode: 'new' })}
174
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
177
+ leftIcon={<Plus size={16} aria-hidden />}
175
178
  >
176
- <Plus className="h-4 w-4" />
177
179
  New Script
178
- </button>
180
+ </Button>
179
181
  </div>
180
182
  </div>
181
183
 
@@ -188,14 +190,16 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
188
190
  <p className="mt-1 text-sm text-[var(--sub)]">
189
191
  Add tracking codes like Google Analytics, Tag Manager, or Facebook Pixel.
190
192
  </p>
191
- <button
193
+ <Button
192
194
  type="button"
195
+ variant="primary"
196
+ size="md"
197
+ className="mt-4"
193
198
  onClick={() => setSelection({ mode: 'new' })}
194
- className="bg-primary text-primary-foreground hover:bg-primary/90 mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
199
+ leftIcon={<Plus size={16} aria-hidden />}
195
200
  >
196
- <Plus className="h-4 w-4" />
197
201
  Add Your First Script
198
- </button>
202
+ </Button>
199
203
  </div>
200
204
  ) : (
201
205
  <div className="overflow-hidden rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
@@ -21,6 +21,8 @@ import { AIGenerateDialog } from './AIGenerateDialog.js'
21
21
 
22
22
  export interface PageBuilderProps {
23
23
  documentId?: string
24
+ /** Seed a NEW page from a saved template (`/page-builder/new?template=<id>`). */
25
+ templateId?: string
24
26
  collectionSlug: string
25
27
  config: any
26
28
  onNavigate: (path: string) => void
@@ -55,7 +57,13 @@ function initialSectionsCollapsed(): boolean {
55
57
  return false
56
58
  }
57
59
 
58
- export function PageBuilder({ documentId, collectionSlug, config, onNavigate }: PageBuilderProps) {
60
+ export function PageBuilder({
61
+ documentId,
62
+ templateId,
63
+ collectionSlug,
64
+ config,
65
+ onNavigate,
66
+ }: PageBuilderProps) {
59
67
  const [loading, setLoading] = useState(!!documentId)
60
68
  const [loadError, setLoadError] = useState<string | null>(null)
61
69
  const [saving, setSaving] = useState(false)
@@ -100,6 +108,25 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
100
108
 
101
109
  useEffect(() => {
102
110
  if (!documentId) {
111
+ // A new page can be seeded from a saved template ("Use template" on
112
+ // the Templates grid). Load the template tree, then hand the canvas
113
+ // over as a dirty, unsaved draft.
114
+ if (templateId) {
115
+ let cancelledNew = false
116
+ setLoading(true)
117
+ cmsApi<{ name?: string; tree?: PageNode }>(`/page-templates/${templateId}`).then((res) => {
118
+ if (cancelledNew) return
119
+ if (res.error) {
120
+ toast.error(`Couldn't load template: ${res.error}`)
121
+ } else if (res.data?.tree) {
122
+ replaceTree(res.data.tree)
123
+ }
124
+ setLoading(false)
125
+ })
126
+ return () => {
127
+ cancelledNew = true
128
+ }
129
+ }
103
130
  setLoading(false)
104
131
  return
105
132
  }
@@ -139,7 +166,8 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
139
166
  return () => {
140
167
  cancelled = true
141
168
  }
142
- }, [documentId, collectionSlug])
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- replaceTree/setPageSettings are stable builder actions
170
+ }, [documentId, templateId, collectionSlug])
143
171
 
144
172
  const handleSave = useCallback(async () => {
145
173
  setSaving(true)