@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.
- package/CHANGELOG.md +13 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-tree.test.js +40 -1
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +21 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +11 -9
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/pages-service.d.ts +7 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +11 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +14 -0
- package/dist/lib/pages-tree.d.ts.map +1 -1
- package/dist/lib/pages-tree.js +51 -0
- package/dist/lib/pages-tree.js.map +1 -1
- package/dist/views/FormEditor.js +5 -7
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/HiddenLoginNotFound.d.ts +6 -0
- package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
- package/dist/views/HiddenLoginNotFound.js +6 -0
- package/dist/views/HiddenLoginNotFound.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +2 -1
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.d.ts +4 -0
- package/dist/views/PageEditor.d.ts.map +1 -1
- package/dist/views/PageEditor.js +4 -0
- package/dist/views/PageEditor.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +40 -5
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +40 -9
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/Pages.d.ts +4 -0
- package/dist/views/Pages.d.ts.map +1 -1
- package/dist/views/Pages.js +4 -0
- package/dist/views/Pages.js.map +1 -1
- package/dist/views/Redirects.d.ts +4 -0
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +4 -0
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +2 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/page-builder/PageBuilder.d.ts +3 -1
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +24 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +30 -4
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +11 -1
- package/src/__tests__/lib/pages-tree.test.ts +49 -0
- package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
- package/src/__tests__/views/page-templates.render.test.tsx +40 -0
- package/src/components/TipTapEditor.tsx +11 -9
- package/src/index.ts +3 -0
- package/src/lib/pages-service.ts +15 -0
- package/src/lib/pages-tree.ts +54 -0
- package/src/views/FormEditor.tsx +10 -10
- package/src/views/HiddenLoginNotFound.tsx +6 -0
- package/src/views/MediaBrowser.tsx +8 -9
- package/src/views/PageEditor.tsx +4 -0
- package/src/views/Pages/PagesListView.tsx +54 -9
- package/src/views/Pages/PagesTreeTable.tsx +203 -111
- package/src/views/Pages.tsx +4 -0
- package/src/views/Redirects.tsx +4 -0
- package/src/views/ScriptTags.tsx +12 -8
- package/src/views/page-builder/PageBuilder.tsx +30 -2
- 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 {
|
|
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
|
-
<
|
|
461
|
+
<Button
|
|
423
462
|
type="button"
|
|
463
|
+
variant="primary"
|
|
464
|
+
size="sm"
|
|
465
|
+
className="shadow-sm"
|
|
424
466
|
onClick={() => onNavigate('/pages/new')}
|
|
425
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
1200
|
+
<Button
|
|
1158
1201
|
type="button"
|
|
1202
|
+
variant="primary"
|
|
1203
|
+
size="sm"
|
|
1204
|
+
className="shadow-sm"
|
|
1159
1205
|
onClick={onNewPage}
|
|
1160
|
-
|
|
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
|
-
</
|
|
1209
|
+
</Button>
|
|
1165
1210
|
)}
|
|
1166
1211
|
</td>
|
|
1167
1212
|
</tr>
|
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
<
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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"
|
package/src/views/Pages.tsx
CHANGED
|
@@ -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)
|
package/src/views/Redirects.tsx
CHANGED
|
@@ -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)
|
package/src/views/ScriptTags.tsx
CHANGED
|
@@ -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
|
-
<
|
|
172
|
+
<Button
|
|
172
173
|
type="button"
|
|
174
|
+
variant="primary"
|
|
175
|
+
size="md"
|
|
173
176
|
onClick={() => setSelection({ mode: 'new' })}
|
|
174
|
-
|
|
177
|
+
leftIcon={<Plus size={16} aria-hidden />}
|
|
175
178
|
>
|
|
176
|
-
<Plus className="h-4 w-4" />
|
|
177
179
|
New Script
|
|
178
|
-
</
|
|
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
|
-
<
|
|
193
|
+
<Button
|
|
192
194
|
type="button"
|
|
195
|
+
variant="primary"
|
|
196
|
+
size="md"
|
|
197
|
+
className="mt-4"
|
|
193
198
|
onClick={() => setSelection({ mode: 'new' })}
|
|
194
|
-
|
|
199
|
+
leftIcon={<Plus size={16} aria-hidden />}
|
|
195
200
|
>
|
|
196
|
-
<Plus className="h-4 w-4" />
|
|
197
201
|
Add Your First Script
|
|
198
|
-
</
|
|
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({
|
|
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
|
-
|
|
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)
|