@actuate-media/cms-admin 0.69.1 → 0.71.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 (96) hide show
  1. package/CHANGELOG.md +19 -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/__tests__/views/script-tags.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/script-tags.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/script-tags.render.test.js +148 -0
  16. package/dist/__tests__/views/script-tags.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/TipTapEditor.d.ts.map +1 -1
  19. package/dist/components/TipTapEditor.js +11 -9
  20. package/dist/components/TipTapEditor.js.map +1 -1
  21. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +10 -0
  22. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -1
  23. package/dist/components/script-tags/ScriptTagEditorPane.js +21 -2
  24. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/pages-service.d.ts +7 -0
  30. package/dist/lib/pages-service.d.ts.map +1 -1
  31. package/dist/lib/pages-service.js +11 -0
  32. package/dist/lib/pages-service.js.map +1 -1
  33. package/dist/lib/pages-tree.d.ts +14 -0
  34. package/dist/lib/pages-tree.d.ts.map +1 -1
  35. package/dist/lib/pages-tree.js +51 -0
  36. package/dist/lib/pages-tree.js.map +1 -1
  37. package/dist/views/FormEditor.js +5 -7
  38. package/dist/views/FormEditor.js.map +1 -1
  39. package/dist/views/HiddenLoginNotFound.d.ts +6 -0
  40. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
  41. package/dist/views/HiddenLoginNotFound.js +6 -0
  42. package/dist/views/HiddenLoginNotFound.js.map +1 -1
  43. package/dist/views/MediaBrowser.d.ts.map +1 -1
  44. package/dist/views/MediaBrowser.js +2 -1
  45. package/dist/views/MediaBrowser.js.map +1 -1
  46. package/dist/views/PageEditor.d.ts +4 -0
  47. package/dist/views/PageEditor.d.ts.map +1 -1
  48. package/dist/views/PageEditor.js +4 -0
  49. package/dist/views/PageEditor.js.map +1 -1
  50. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  51. package/dist/views/Pages/PagesListView.js +40 -5
  52. package/dist/views/Pages/PagesListView.js.map +1 -1
  53. package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
  54. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  55. package/dist/views/Pages/PagesTreeTable.js +40 -9
  56. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  57. package/dist/views/Pages.d.ts +4 -0
  58. package/dist/views/Pages.d.ts.map +1 -1
  59. package/dist/views/Pages.js +4 -0
  60. package/dist/views/Pages.js.map +1 -1
  61. package/dist/views/Redirects.d.ts +4 -0
  62. package/dist/views/Redirects.d.ts.map +1 -1
  63. package/dist/views/Redirects.js +4 -0
  64. package/dist/views/Redirects.js.map +1 -1
  65. package/dist/views/ScriptTags.d.ts.map +1 -1
  66. package/dist/views/ScriptTags.js +122 -15
  67. package/dist/views/ScriptTags.js.map +1 -1
  68. package/dist/views/page-builder/PageBuilder.d.ts +3 -1
  69. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  70. package/dist/views/page-builder/PageBuilder.js +24 -2
  71. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  72. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  73. package/dist/views/page-builder/PageTemplates.js +30 -4
  74. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/AdminRoot.tsx +11 -1
  77. package/src/__tests__/lib/pages-tree.test.ts +49 -0
  78. package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
  79. package/src/__tests__/views/page-templates.render.test.tsx +40 -0
  80. package/src/__tests__/views/script-tags.render.test.tsx +178 -0
  81. package/src/components/TipTapEditor.tsx +11 -9
  82. package/src/components/script-tags/ScriptTagEditorPane.tsx +40 -4
  83. package/src/index.ts +3 -0
  84. package/src/lib/pages-service.ts +15 -0
  85. package/src/lib/pages-tree.ts +54 -0
  86. package/src/views/FormEditor.tsx +10 -10
  87. package/src/views/HiddenLoginNotFound.tsx +6 -0
  88. package/src/views/MediaBrowser.tsx +8 -9
  89. package/src/views/PageEditor.tsx +4 -0
  90. package/src/views/Pages/PagesListView.tsx +54 -9
  91. package/src/views/Pages/PagesTreeTable.tsx +203 -111
  92. package/src/views/Pages.tsx +4 -0
  93. package/src/views/Redirects.tsx +4 -0
  94. package/src/views/ScriptTags.tsx +268 -30
  95. package/src/views/page-builder/PageBuilder.tsx +30 -2
  96. package/src/views/page-builder/PageTemplates.tsx +56 -15
@@ -12,6 +12,10 @@ export interface PageEditorProps {
12
12
  onNavigate?: (path: string) => void
13
13
  }
14
14
 
15
+ /**
16
+ * @deprecated Superseded by the visual page builder (`PageBuilder` and the
17
+ * `views/page-editor/` section editor); will be removed in the next major.
18
+ */
15
19
  export function PageEditor({ id, onNavigate }: PageEditorProps) {
16
20
  const isNew = !id
17
21
  const { data, loading, error } = useApiData<any>(isNew ? '' : `/collections/pages/${id}`)
@@ -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)