@actuate-media/cms-admin 0.69.0 → 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 (188) 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/form-entries.render.test.js +14 -4
  8. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  9. package/dist/__tests__/views/form-submissions.render.test.js +14 -4
  10. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  11. package/dist/__tests__/views/media-seo-audit.render.test.js +26 -0
  12. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -1
  13. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
  16. package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
  17. package/dist/__tests__/views/page-templates.render.test.js +21 -0
  18. package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
  19. package/dist/__tests__/views/redirects-pane.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/redirects-pane.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/redirects-pane.render.test.js +121 -0
  22. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -0
  23. package/dist/actuate-admin.css +1 -1
  24. package/dist/components/SEOPanel.js +3 -3
  25. package/dist/components/SEOPanel.js.map +1 -1
  26. package/dist/components/TipTapEditor.d.ts.map +1 -1
  27. package/dist/components/TipTapEditor.js +11 -9
  28. package/dist/components/TipTapEditor.js.map +1 -1
  29. package/dist/components/collections/EntryDetailPane.js +1 -1
  30. package/dist/components/collections/EntryDetailPane.js.map +1 -1
  31. package/dist/components/forms/EntriesTable.d.ts +3 -1
  32. package/dist/components/forms/EntriesTable.d.ts.map +1 -1
  33. package/dist/components/forms/EntriesTable.js +5 -2
  34. package/dist/components/forms/EntriesTable.js.map +1 -1
  35. package/dist/components/forms/FormEntryDetailPane.d.ts +10 -0
  36. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -0
  37. package/dist/components/forms/FormEntryDetailPane.js +137 -0
  38. package/dist/components/forms/FormEntryDetailPane.js.map +1 -0
  39. package/dist/components/forms/FormSchemaPane.js +1 -1
  40. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  41. package/dist/components/script-tags/ScriptTagEditorPane.js +1 -1
  42. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  43. package/dist/components/seo/RedirectEditorPane.d.ts +12 -0
  44. package/dist/components/seo/RedirectEditorPane.d.ts.map +1 -0
  45. package/dist/components/seo/RedirectEditorPane.js +89 -0
  46. package/dist/components/seo/RedirectEditorPane.js.map +1 -0
  47. package/dist/components/seo/SeoEditorPane.js +1 -1
  48. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  49. package/dist/components/ui/DataTable.js +2 -2
  50. package/dist/components/ui/DataTable.js.map +1 -1
  51. package/dist/components/ui/Skeleton.js +3 -3
  52. package/dist/components/ui/Skeleton.js.map +1 -1
  53. package/dist/components/ui/Toggle.js +1 -1
  54. package/dist/components/ui/Toggle.js.map +1 -1
  55. package/dist/fields/BlockBuilderField.js +1 -1
  56. package/dist/fields/BlockBuilderField.js.map +1 -1
  57. package/dist/fields/MediaField.js +1 -1
  58. package/dist/fields/MediaField.js.map +1 -1
  59. package/dist/fields/PropInput.js +1 -1
  60. package/dist/fields/PropInput.js.map +1 -1
  61. package/dist/index.d.ts +3 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +3 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/layout/Header.js +1 -1
  66. package/dist/layout/Header.js.map +1 -1
  67. package/dist/lib/pages-service.d.ts +7 -0
  68. package/dist/lib/pages-service.d.ts.map +1 -1
  69. package/dist/lib/pages-service.js +11 -0
  70. package/dist/lib/pages-service.js.map +1 -1
  71. package/dist/lib/pages-tree.d.ts +14 -0
  72. package/dist/lib/pages-tree.d.ts.map +1 -1
  73. package/dist/lib/pages-tree.js +51 -0
  74. package/dist/lib/pages-tree.js.map +1 -1
  75. package/dist/views/DocumentEdit.js +1 -1
  76. package/dist/views/DocumentEdit.js.map +1 -1
  77. package/dist/views/FormEditor.js +5 -7
  78. package/dist/views/FormEditor.js.map +1 -1
  79. package/dist/views/FormEntries.d.ts.map +1 -1
  80. package/dist/views/FormEntries.js +18 -14
  81. package/dist/views/FormEntries.js.map +1 -1
  82. package/dist/views/FormSubmissions.d.ts.map +1 -1
  83. package/dist/views/FormSubmissions.js +18 -15
  84. package/dist/views/FormSubmissions.js.map +1 -1
  85. package/dist/views/Forms.js +5 -5
  86. package/dist/views/Forms.js.map +1 -1
  87. package/dist/views/HiddenLoginNotFound.d.ts +6 -0
  88. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
  89. package/dist/views/HiddenLoginNotFound.js +6 -0
  90. package/dist/views/HiddenLoginNotFound.js.map +1 -1
  91. package/dist/views/MediaBrowser.d.ts.map +1 -1
  92. package/dist/views/MediaBrowser.js +3 -2
  93. package/dist/views/MediaBrowser.js.map +1 -1
  94. package/dist/views/MediaSeoAudit.d.ts +4 -1
  95. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  96. package/dist/views/MediaSeoAudit.js +12 -2
  97. package/dist/views/MediaSeoAudit.js.map +1 -1
  98. package/dist/views/PageEditor.d.ts +4 -0
  99. package/dist/views/PageEditor.d.ts.map +1 -1
  100. package/dist/views/PageEditor.js +4 -0
  101. package/dist/views/PageEditor.js.map +1 -1
  102. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  103. package/dist/views/Pages/PagesListView.js +52 -17
  104. package/dist/views/Pages/PagesListView.js.map +1 -1
  105. package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
  106. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  107. package/dist/views/Pages/PagesTreeTable.js +40 -9
  108. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  109. package/dist/views/Pages.d.ts +4 -0
  110. package/dist/views/Pages.d.ts.map +1 -1
  111. package/dist/views/Pages.js +4 -0
  112. package/dist/views/Pages.js.map +1 -1
  113. package/dist/views/Redirects.d.ts +4 -0
  114. package/dist/views/Redirects.d.ts.map +1 -1
  115. package/dist/views/Redirects.js +4 -0
  116. package/dist/views/Redirects.js.map +1 -1
  117. package/dist/views/ScriptTags.d.ts.map +1 -1
  118. package/dist/views/ScriptTags.js +4 -3
  119. package/dist/views/ScriptTags.js.map +1 -1
  120. package/dist/views/page-builder/PageBuilder.d.ts +3 -1
  121. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  122. package/dist/views/page-builder/PageBuilder.js +24 -2
  123. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  124. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  125. package/dist/views/page-builder/PageTemplates.js +30 -4
  126. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  127. package/dist/views/seo/ContentTab.js +6 -6
  128. package/dist/views/seo/ContentTab.js.map +1 -1
  129. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  130. package/dist/views/seo/RedirectsTab.js +23 -8
  131. package/dist/views/seo/RedirectsTab.js.map +1 -1
  132. package/package.json +1 -1
  133. package/src/AdminRoot.tsx +11 -1
  134. package/src/__tests__/lib/pages-tree.test.ts +49 -0
  135. package/src/__tests__/views/form-entries.render.test.tsx +16 -4
  136. package/src/__tests__/views/form-submissions.render.test.tsx +16 -4
  137. package/src/__tests__/views/media-seo-audit.render.test.tsx +34 -0
  138. package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
  139. package/src/__tests__/views/page-templates.render.test.tsx +40 -0
  140. package/src/__tests__/views/redirects-pane.render.test.tsx +156 -0
  141. package/src/components/SEOPanel.tsx +9 -9
  142. package/src/components/TipTapEditor.tsx +11 -9
  143. package/src/components/collections/EntryDetailPane.tsx +1 -1
  144. package/src/components/forms/EntriesTable.tsx +12 -3
  145. package/src/components/forms/{EntryDetailDrawer.tsx → FormEntryDetailPane.tsx} +112 -102
  146. package/src/components/forms/FormSchemaPane.tsx +1 -1
  147. package/src/components/script-tags/ScriptTagEditorPane.tsx +1 -1
  148. package/src/components/seo/RedirectEditorPane.tsx +271 -0
  149. package/src/components/seo/SeoEditorPane.tsx +1 -1
  150. package/src/components/ui/DataTable.tsx +3 -3
  151. package/src/components/ui/Skeleton.tsx +8 -8
  152. package/src/components/ui/Toggle.tsx +1 -1
  153. package/src/fields/BlockBuilderField.tsx +1 -1
  154. package/src/fields/MediaField.tsx +1 -1
  155. package/src/fields/PropInput.tsx +1 -1
  156. package/src/index.ts +3 -0
  157. package/src/layout/Header.tsx +2 -2
  158. package/src/lib/pages-service.ts +15 -0
  159. package/src/lib/pages-tree.ts +54 -0
  160. package/src/styles/actuate-tokens.css +4 -0
  161. package/src/styles/theme.css +9 -3
  162. package/src/views/DocumentEdit.tsx +4 -4
  163. package/src/views/FormEditor.tsx +10 -10
  164. package/src/views/FormEntries.tsx +81 -66
  165. package/src/views/FormSubmissions.tsx +78 -64
  166. package/src/views/Forms.tsx +16 -16
  167. package/src/views/HiddenLoginNotFound.tsx +6 -0
  168. package/src/views/MediaBrowser.tsx +14 -10
  169. package/src/views/MediaSeoAudit.tsx +39 -2
  170. package/src/views/PageEditor.tsx +4 -0
  171. package/src/views/Pages/PagesListView.tsx +99 -54
  172. package/src/views/Pages/PagesTreeTable.tsx +204 -109
  173. package/src/views/Pages.tsx +4 -0
  174. package/src/views/Redirects.tsx +4 -0
  175. package/src/views/ScriptTags.tsx +36 -42
  176. package/src/views/page-builder/PageBuilder.tsx +30 -2
  177. package/src/views/page-builder/PageTemplates.tsx +56 -15
  178. package/src/views/seo/ContentTab.tsx +10 -10
  179. package/src/views/seo/RedirectsTab.tsx +145 -109
  180. package/dist/components/forms/EntryDetailDrawer.d.ts +0 -11
  181. package/dist/components/forms/EntryDetailDrawer.d.ts.map +0 -1
  182. package/dist/components/forms/EntryDetailDrawer.js +0 -145
  183. package/dist/components/forms/EntryDetailDrawer.js.map +0 -1
  184. package/dist/components/seo/RedirectEditorDrawer.d.ts +0 -9
  185. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +0 -1
  186. package/dist/components/seo/RedirectEditorDrawer.js +0 -95
  187. package/dist/components/seo/RedirectEditorDrawer.js.map +0 -1
  188. package/src/components/seo/RedirectEditorDrawer.tsx +0 -231
@@ -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,122 +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="border-border accent-primary rounded"
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="text-foreground mb-2 text-sm font-medium">{error}</div>
117
- <button onClick={onRetry} className="text-primary text-sm hover:underline">
118
- Try again
119
- </button>
120
- </td>
121
- </tr>
122
- )}
123
- {!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)]">
124
132
  <tr>
125
- <td colSpan={8} className="px-3 py-16 text-center">
126
- <div className="text-foreground mb-2 text-sm font-medium">
127
- {flattened ? 'No pages match your filters' : 'No pages yet'}
128
- </div>
129
- <div className="text-muted-foreground mb-4 text-sm">
130
- {flattened
131
- ? 'Try clearing the search or status filter above.'
132
- : 'Create your first page to get started.'}
133
- </div>
134
- {flattened ? (
135
- <button
136
- type="button"
137
- onClick={onClearFilters}
138
- className="text-primary text-sm hover:underline"
139
- >
140
- Clear filters
141
- </button>
142
- ) : (
143
- <button
144
- type="button"
145
- onClick={onNewPage}
146
- 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"
147
- >
148
- <Plus className="h-3.5 w-3.5" aria-hidden />
149
- New page
150
- </button>
151
- )}
152
- </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>
153
163
  </tr>
154
- )}
155
- {!loading &&
156
- !error &&
157
- rows.map((row) => (
158
- <TreeRow
159
- key={row.page.id}
160
- row={row}
161
- isSelected={selected.has(row.page.id)}
162
- isExpanded={expanded.has(row.page.id)}
163
- onToggleExpand={() => onToggleExpand(row.page.id)}
164
- onToggleSelect={() => onToggleSelect(row.page.id)}
165
- onOpen={() => onOpen(row.page)}
166
- onAddSubPage={() => onAddSubPage(row.page)}
167
- onDuplicate={() => onDuplicate(row.page)}
168
- onDelete={() => onDelete(row.page)}
169
- />
170
- ))}
171
- </tbody>
172
- </table>
173
- </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>
174
233
  )
175
234
  }
176
235
 
@@ -178,6 +237,8 @@ function TreeRow({
178
237
  row,
179
238
  isSelected,
180
239
  isExpanded,
240
+ draggable,
241
+ isDropTarget,
181
242
  onToggleExpand,
182
243
  onToggleSelect,
183
244
  onOpen,
@@ -188,6 +249,9 @@ function TreeRow({
188
249
  row: PageTreeRow
189
250
  isSelected: boolean
190
251
  isExpanded: boolean
252
+ draggable: boolean
253
+ /** true while another row is being dragged and this row is a valid parent */
254
+ isDropTarget: boolean
191
255
  onToggleExpand: () => void
192
256
  onToggleSelect: () => void
193
257
  onOpen: () => void
@@ -196,10 +260,28 @@ function TreeRow({
196
260
  onDelete: () => void
197
261
  }) {
198
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
+
199
274
  return (
200
275
  <tr
276
+ ref={setDropRef}
201
277
  className={`group h-14 border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] ${
202
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
+ : ''
203
285
  }`}
204
286
  >
205
287
  <td className="px-3 align-middle">
@@ -208,13 +290,26 @@ function TreeRow({
208
290
  aria-label={`Select "${page.title}"`}
209
291
  checked={isSelected}
210
292
  onChange={onToggleSelect}
211
- className="border-border accent-primary rounded"
293
+ className="accent-primary rounded border-[var(--bdr)]"
212
294
  />
213
295
  </td>
214
296
  <td className="px-3 align-middle">
215
297
  {/* Indentation is data-driven (24px per depth level), so it must be
216
298
  an inline style rather than a class. */}
217
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
+ )}
218
313
  {hasChildren ? (
219
314
  <button
220
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 {
@@ -139,7 +140,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
139
140
  <span className="text-destructive flex-1 text-sm">{error}</span>
140
141
  <button
141
142
  onClick={refetch}
142
- className="border-destructive/40 text-destructive hover:bg-destructive/15 rounded-lg border px-3 py-1 text-sm transition-colors"
143
+ className="border-destructive/40 text-destructive hover:bg-destructive/15 rounded-lg border px-3 py-1 text-sm transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
143
144
  >
144
145
  Retry
145
146
  </button>
@@ -148,8 +149,8 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
148
149
 
149
150
  <div className="mb-4 flex items-center justify-between">
150
151
  <div>
151
- <h1 className="text-foreground mb-1">Script Tags</h1>
152
- <p className="text-muted-foreground text-sm">
152
+ <h1 className="mb-1 text-[var(--txt)]">Script Tags</h1>
153
+ <p className="text-sm text-[var(--sub)]">
153
154
  Manage tracking codes, analytics, and custom scripts injected into your site
154
155
  </p>
155
156
  {tags.length > 0 && (
@@ -168,59 +169,52 @@ 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"
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
 
182
184
  <SplitPaneLayout>
183
185
  <SplitPaneList className="p-3 pt-0 pr-6 sm:p-4 sm:pt-0 sm:pr-8">
184
186
  {tags.length === 0 && !error ? (
185
- <div className="border-border bg-card rounded-lg border p-12 text-center">
186
- <Code2 className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
187
- <h3 className="text-foreground text-sm font-medium">No script tags yet</h3>
188
- <p className="text-muted-foreground mt-1 text-sm">
187
+ <div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)] p-12 text-center">
188
+ <Code2 className="mx-auto mb-3 h-10 w-10 text-[var(--sub)]" />
189
+ <h3 className="text-sm font-medium text-[var(--txt)]">No script tags yet</h3>
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"
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
- <div className="border-border bg-card overflow-hidden rounded-lg border">
205
+ <div className="overflow-hidden rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
202
206
  <table className="w-full text-sm" aria-label="Script tags">
203
207
  <thead>
204
- <tr className="border-border bg-muted border-b">
205
- <th className="text-muted-foreground px-4 py-3 text-left font-medium">
206
- Script
207
- </th>
208
- <th className="text-muted-foreground px-4 py-3 text-left font-medium">
209
- Location
210
- </th>
211
- <th className="text-muted-foreground px-4 py-3 text-center font-medium">
208
+ <tr className="bg-muted border-b border-[var(--bdr)]">
209
+ <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Script</th>
210
+ <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Location</th>
211
+ <th className="px-4 py-3 text-center font-medium text-[var(--sub)]">
212
212
  Priority
213
213
  </th>
214
- <th className="text-muted-foreground px-4 py-3 text-left font-medium">Scope</th>
215
- <th className="text-muted-foreground px-4 py-3 text-left font-medium">
216
- Consent
217
- </th>
218
- <th className="text-muted-foreground px-4 py-3 text-center font-medium">
219
- Active
220
- </th>
221
- <th className="text-muted-foreground px-4 py-3 text-left font-medium">
222
- Updated
223
- </th>
214
+ <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Scope</th>
215
+ <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Consent</th>
216
+ <th className="px-4 py-3 text-center font-medium text-[var(--sub)]">Active</th>
217
+ <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Updated</th>
224
218
  </tr>
225
219
  </thead>
226
220
  <tbody>
@@ -229,8 +223,8 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
229
223
  return (
230
224
  <tr
231
225
  key={tag.id}
232
- className={`border-border cursor-pointer border-b transition-colors last:border-0 ${
233
- isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-muted'
226
+ className={`cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-0 ${
227
+ isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
234
228
  }`}
235
229
  onClick={() => toggleRow(tag)}
236
230
  >
@@ -242,29 +236,29 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
242
236
  toggleRow(tag)
243
237
  }}
244
238
  aria-expanded={isSelected}
245
- className="text-foreground hover:text-primary text-left font-medium"
239
+ className="text-left font-medium text-[var(--txt)] hover:text-[var(--acc)]"
246
240
  >
247
241
  {tag.name}
248
242
  </button>
249
243
  </td>
250
244
  <td className="px-4 py-[11px]">
251
245
  <span
252
- className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${PLACEMENT_COLORS[tag.placement] ?? 'bg-muted text-foreground'}`}
246
+ className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${PLACEMENT_COLORS[tag.placement] ?? 'bg-muted text-[var(--txt)]'}`}
253
247
  >
254
248
  {PLACEMENT_LABELS[tag.placement] ?? tag.placement}
255
249
  </span>
256
250
  </td>
257
- <td className="text-muted-foreground px-4 py-[11px] text-center font-mono">
251
+ <td className="px-4 py-[11px] text-center font-mono text-[var(--sub)]">
258
252
  {tag.priority}
259
253
  </td>
260
- <td className="text-muted-foreground px-4 py-[11px]">{scopeLabel(tag)}</td>
254
+ <td className="px-4 py-[11px] text-[var(--sub)]">{scopeLabel(tag)}</td>
261
255
  <td className="px-4 py-[11px]">
262
256
  {tag.consentCategory && tag.consentCategory !== 'none' ? (
263
257
  <span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-1.5 py-0.5 text-[11px] text-[var(--sub)]">
264
258
  {CONSENT_LABELS[tag.consentCategory] ?? tag.consentCategory}
265
259
  </span>
266
260
  ) : (
267
- <span className="text-muted-foreground text-xs">—</span>
261
+ <span className="text-xs text-[var(--sub)]">—</span>
268
262
  )}
269
263
  </td>
270
264
  <td
@@ -278,7 +272,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
278
272
  aria-label={`${tag.enabled ? 'Disable' : 'Enable'} ${tag.name}`}
279
273
  />
280
274
  </td>
281
- <td className="text-muted-foreground px-4 py-[11px] text-xs">
275
+ <td className="px-4 py-[11px] text-xs text-[var(--sub)]">
282
276
  {formatUpdated(tag.updatedAt)}
283
277
  </td>
284
278
  </tr>
@@ -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)