@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
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  buildPageTree,
4
+ checkReparent,
5
+ collectDescendantIds,
4
6
  defaultExpandedIds,
5
7
  flattenPageTree,
6
8
  type PageTreeNode,
@@ -195,3 +197,50 @@ describe('defaultExpandedIds', () => {
195
197
  expect([...defaultExpandedIds(roots)]).toEqual(['about'])
196
198
  })
197
199
  })
200
+
201
+ describe('collectDescendantIds', () => {
202
+ it('returns every id in the subtree, excluding the page itself', () => {
203
+ const roots = buildPageTree([
204
+ page({ id: 'about', title: 'About Us', path: '/about' }),
205
+ page({ id: 'team', title: 'Our Team', path: '/about/team' }),
206
+ page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
207
+ page({ id: 'blog', title: 'Blog', path: '/blog' }),
208
+ ])
209
+ expect([...collectDescendantIds(roots, 'about')].sort()).toEqual(['deep', 'team'])
210
+ expect([...collectDescendantIds(roots, 'blog')]).toEqual([])
211
+ expect([...collectDescendantIds(roots, 'missing')]).toEqual([])
212
+ })
213
+ })
214
+
215
+ describe('checkReparent', () => {
216
+ const roots = () =>
217
+ buildPageTree([
218
+ page({ id: 'about', title: 'About Us', path: '/about' }),
219
+ page({ id: 'team', title: 'Our Team', path: '/about/team' }),
220
+ page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
221
+ page({ id: 'blog', title: 'Blog', path: '/blog' }),
222
+ ])
223
+
224
+ it('allows moving a page under an unrelated page', () => {
225
+ expect(checkReparent(roots(), 'blog', 'team')).toEqual({ ok: true })
226
+ })
227
+
228
+ it('allows moving a subtree up to an ancestor that is not its parent', () => {
229
+ // deep currently sits under team; moving it directly under about is legal.
230
+ expect(checkReparent(roots(), 'deep', 'about')).toEqual({ ok: true })
231
+ })
232
+
233
+ it('rejects self-drops', () => {
234
+ expect(checkReparent(roots(), 'about', 'about').ok).toBe(false)
235
+ })
236
+
237
+ it('rejects drops into the page own subtree (cycle)', () => {
238
+ expect(checkReparent(roots(), 'about', 'team').ok).toBe(false)
239
+ expect(checkReparent(roots(), 'about', 'deep').ok).toBe(false)
240
+ })
241
+
242
+ it('rejects no-op moves onto the current parent', () => {
243
+ expect(checkReparent(roots(), 'team', 'about').ok).toBe(false)
244
+ expect(checkReparent(roots(), 'deep', 'team').ok).toBe(false)
245
+ })
246
+ })
@@ -0,0 +1,59 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'
3
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ const cmsApi = vi.fn()
6
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
7
+ vi.mock('sonner', () => ({
8
+ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() },
9
+ Toaster: () => null,
10
+ }))
11
+
12
+ const { PageBuilder } = await import('../../views/page-builder/PageBuilder.js')
13
+
14
+ const TEMPLATE_TREE = {
15
+ id: 'tpl-page',
16
+ type: 'page',
17
+ children: [
18
+ {
19
+ id: 'sec1',
20
+ type: 'section',
21
+ settings: { label: 'Hero From Template' },
22
+ children: [],
23
+ },
24
+ ],
25
+ }
26
+
27
+ beforeEach(() => {
28
+ cmsApi.mockReset()
29
+ cmsApi.mockImplementation(async (endpoint: string) => {
30
+ if (endpoint === '/page-templates/tpl-1') {
31
+ return { data: { id: 'tpl-1', name: 'Landing', tree: TEMPLATE_TREE }, status: 200 }
32
+ }
33
+ return { data: {}, status: 200 }
34
+ })
35
+ })
36
+
37
+ afterEach(cleanup)
38
+
39
+ describe('PageBuilder template seeding (/page-builder/new?template=)', () => {
40
+ it('fetches the template and seeds the canvas tree for a new page', async () => {
41
+ render(
42
+ <PageBuilder templateId="tpl-1" collectionSlug="pages" config={{}} onNavigate={() => {}} />,
43
+ )
44
+
45
+ await waitFor(() => expect(cmsApi).toHaveBeenCalledWith('/page-templates/tpl-1'))
46
+ // replaceTree marks the builder dirty — the unsaved-changes dot is the
47
+ // observable proof the template tree was seeded into the canvas state.
48
+ expect(await screen.findByTitle('Unsaved changes')).toBeTruthy()
49
+ })
50
+
51
+ it('starts from a blank, clean tree when no template id is given', async () => {
52
+ render(<PageBuilder collectionSlug="pages" config={{}} onNavigate={() => {}} />)
53
+
54
+ await waitFor(() =>
55
+ expect(cmsApi).not.toHaveBeenCalledWith(expect.stringContaining('/page-templates/')),
56
+ )
57
+ expect(screen.queryByTitle('Unsaved changes')).toBeNull()
58
+ })
59
+ })
@@ -119,6 +119,46 @@ describe('PageTemplates card grid', () => {
119
119
  })
120
120
  })
121
121
 
122
+ it('routes "Use template" into the builder with the template id', async () => {
123
+ const onNavigate = vi.fn()
124
+ render(<PageTemplates onNavigate={onNavigate} />)
125
+ await screen.findByText('Landing Page')
126
+
127
+ fireEvent.click(screen.getByRole('button', { name: 'Create page from Landing Page' }))
128
+
129
+ expect(onNavigate).toHaveBeenCalledWith('/page-builder/new?template=builtin-landing')
130
+ })
131
+
132
+ it('duplicates a template (built-ins included) via POST with a "(copy)" name', async () => {
133
+ render(<PageTemplates onNavigate={() => {}} />)
134
+ await screen.findByText('Landing Page')
135
+
136
+ fireEvent.click(screen.getByRole('button', { name: 'Duplicate Landing Page' }))
137
+
138
+ await waitFor(() =>
139
+ expect(cmsApi).toHaveBeenCalledWith(
140
+ '/page-templates',
141
+ expect.objectContaining({ method: 'POST' }),
142
+ ),
143
+ )
144
+ const postCall = cmsApi.mock.calls.find(
145
+ (c) => (c[1] as RequestInit | undefined)?.method === 'POST',
146
+ )!
147
+ expect(JSON.parse((postCall[1] as RequestInit).body as string)).toMatchObject({
148
+ name: 'Landing Page (copy)',
149
+ category: 'landing',
150
+ tree: TEMPLATES[0]!.tree,
151
+ })
152
+ // The grid refetches so the copy appears.
153
+ await waitFor(() =>
154
+ expect(
155
+ cmsApi.mock.calls.filter(
156
+ (c) => c[0] === '/page-templates' && !(c[1] as RequestInit | undefined)?.method,
157
+ ).length,
158
+ ).toBeGreaterThanOrEqual(2),
159
+ )
160
+ })
161
+
122
162
  it('filters cards by search text', async () => {
123
163
  render(<PageTemplates onNavigate={() => {}} />)
124
164
  await screen.findByText('Team Member')
@@ -0,0 +1,178 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Drive the Script Tags list through a mocked cmsApi so the test owns the
6
+ // data and can assert the design-parity surface: stat pills, search,
7
+ // location/consent filter pills, badges, and row actions.
8
+ function makeTag(over: Record<string, unknown> = {}) {
9
+ return {
10
+ id: 't1',
11
+ name: 'Google Analytics 4',
12
+ code: '<script>/* ga */</script>',
13
+ placement: 'head',
14
+ scope: 'site',
15
+ targetPaths: [],
16
+ priority: 1,
17
+ enabled: true,
18
+ loadAsync: true,
19
+ disableInPreview: true,
20
+ consentCategory: 'analytics',
21
+ createdAt: '2026-05-20T10:00:00.000Z',
22
+ updatedAt: '2026-05-20T10:00:00.000Z',
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ const TAGS = [
28
+ makeTag(),
29
+ makeTag({
30
+ id: 't2',
31
+ name: 'Live Chat Widget',
32
+ placement: 'body_close',
33
+ priority: 5,
34
+ consentCategory: 'functional',
35
+ }),
36
+ makeTag({
37
+ id: 't3',
38
+ name: 'Org Schema',
39
+ placement: 'head',
40
+ priority: 3,
41
+ enabled: false,
42
+ consentCategory: 'none',
43
+ }),
44
+ ]
45
+
46
+ const cmsApi = vi.fn(async (endpoint: string, init?: RequestInit) => {
47
+ if (!init?.method) return { data: TAGS }
48
+ if (init.method === 'POST') return { data: makeTag({ id: 'new-copy' }) }
49
+ return { data: {} }
50
+ })
51
+
52
+ vi.mock('../../lib/api.js', () => ({
53
+ cmsApi: (endpoint: string, init?: RequestInit) => cmsApi(endpoint, init),
54
+ }))
55
+
56
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
57
+
58
+ const { ScriptTags } = await import('../../views/ScriptTags.js')
59
+
60
+ beforeEach(() => {
61
+ cmsApi.mockClear()
62
+ })
63
+
64
+ describe('ScriptTags list', () => {
65
+ it('renders rows, stat pills (incl. consent-gated), and colored badges', async () => {
66
+ render(<ScriptTags />)
67
+
68
+ expect(await screen.findByText('Google Analytics 4')).toBeTruthy()
69
+ expect(screen.getByText('Live Chat Widget')).toBeTruthy()
70
+
71
+ // Stat pills: 3 total, 2 active, 1 inactive, 2 consent-gated.
72
+ expect(screen.getByText('Consent-gated')).toBeTruthy()
73
+ expect(screen.getByText('Consent-gated').closest('span')?.textContent).toContain('2')
74
+
75
+ // Location badges use the design palette (head=accent, body_close=info).
76
+ // Note: "Body end" also matches the filter pill, so pick the badge chip.
77
+ const headBadge = screen.getAllByText('Head').find((el) => el.className.includes('--acc-l'))
78
+ expect(headBadge).toBeTruthy()
79
+ const bodyEndBadge = screen
80
+ .getAllByText('Body end')
81
+ .find((el) => el.className.includes('--info-l'))
82
+ expect(bodyEndBadge).toBeTruthy()
83
+
84
+ // Consent badges are per-category colored chips.
85
+ const analyticsBadge = screen
86
+ .getAllByText('Analytics')
87
+ .find((el) => el.className.includes('--acc-l'))
88
+ expect(analyticsBadge).toBeTruthy()
89
+ const functionalBadge = screen
90
+ .getAllByText('Functional')
91
+ .find((el) => el.className.includes('--suc-l'))
92
+ expect(functionalBadge).toBeTruthy()
93
+ })
94
+
95
+ it('filters by location pill and shows the filtered count', async () => {
96
+ render(<ScriptTags />)
97
+ await screen.findByText('Google Analytics 4')
98
+
99
+ expect(screen.getByText('3 scripts')).toBeTruthy()
100
+ fireEvent.click(screen.getByRole('button', { name: 'Body end', pressed: false }))
101
+
102
+ await waitFor(() => {
103
+ expect(screen.getByText('Live Chat Widget')).toBeTruthy()
104
+ expect(screen.queryByText('Google Analytics 4')).toBeNull()
105
+ expect(screen.getByText('1 script')).toBeTruthy()
106
+ })
107
+ })
108
+
109
+ it('filters by consent pill', async () => {
110
+ render(<ScriptTags />)
111
+ await screen.findByText('Google Analytics 4')
112
+
113
+ fireEvent.click(screen.getByRole('button', { name: 'No consent', pressed: false }))
114
+
115
+ await waitFor(() => {
116
+ expect(screen.getByText('Org Schema')).toBeTruthy()
117
+ expect(screen.queryByText('Google Analytics 4')).toBeNull()
118
+ })
119
+ })
120
+
121
+ it('searches by name and offers clear-filters from the empty state', async () => {
122
+ render(<ScriptTags />)
123
+ await screen.findByText('Google Analytics 4')
124
+
125
+ fireEvent.change(screen.getByPlaceholderText('Search scripts…'), {
126
+ target: { value: 'zzz-no-match' },
127
+ })
128
+
129
+ expect(await screen.findByText('No scripts match your filters')).toBeTruthy()
130
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
131
+ expect(await screen.findByText('Google Analytics 4')).toBeTruthy()
132
+ })
133
+
134
+ it('select-all checks visible rows and shows selected count with clear', async () => {
135
+ render(<ScriptTags />)
136
+ await screen.findByText('Google Analytics 4')
137
+
138
+ fireEvent.click(screen.getByLabelText('Select all scripts'))
139
+ expect(await screen.findByText(/3 selected/)).toBeTruthy()
140
+
141
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
142
+ await waitFor(() => expect(screen.queryByText(/selected/)).toBeNull())
143
+ })
144
+
145
+ it('row duplicate posts a disabled copy and refetches', async () => {
146
+ render(<ScriptTags />)
147
+ await screen.findByText('Google Analytics 4')
148
+
149
+ fireEvent.click(screen.getByRole('button', { name: 'Duplicate Google Analytics 4' }))
150
+
151
+ await waitFor(() => {
152
+ const post = cmsApi.mock.calls.find(
153
+ (c) => (c[1] as RequestInit | undefined)?.method === 'POST',
154
+ )
155
+ expect(post).toBeTruthy()
156
+ const body = JSON.parse(String((post![1] as RequestInit).body))
157
+ expect(body.name).toBe('Google Analytics 4 (Copy)')
158
+ expect(body.enabled).toBe(false)
159
+ })
160
+ })
161
+
162
+ it('row delete asks for confirmation, then issues DELETE', async () => {
163
+ render(<ScriptTags />)
164
+ await screen.findByText('Org Schema')
165
+
166
+ fireEvent.click(screen.getByRole('button', { name: 'Delete Org Schema' }))
167
+ expect(await screen.findByText('Delete script?')).toBeTruthy()
168
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
169
+
170
+ await waitFor(() => {
171
+ const del = cmsApi.mock.calls.find(
172
+ (c) => (c[1] as RequestInit | undefined)?.method === 'DELETE',
173
+ )
174
+ expect(del).toBeTruthy()
175
+ expect(del![0]).toBe('/script-tags/t3')
176
+ })
177
+ })
178
+ })
@@ -155,13 +155,13 @@ const editorStyles = `
155
155
  }
156
156
  .ProseMirror th,
157
157
  .ProseMirror td {
158
- border: 1px solid #d1d5db;
158
+ border: 1px solid var(--bdr);
159
159
  padding: 0.5rem;
160
160
  text-align: left;
161
161
  min-width: 80px;
162
162
  }
163
163
  .ProseMirror th {
164
- background-color: #f3f4f6;
164
+ background-color: var(--surface-muted);
165
165
  font-weight: 600;
166
166
  }
167
167
  .ProseMirror img {
@@ -170,11 +170,13 @@ const editorStyles = `
170
170
  border-radius: 0.375rem;
171
171
  }
172
172
  .ProseMirror blockquote {
173
- border-left: 3px solid #3b82f6;
173
+ border-left: 3px solid var(--acc);
174
174
  padding-left: 1rem;
175
175
  margin: 1rem 0;
176
- color: #4b5563;
176
+ color: var(--sub);
177
177
  }
178
+ /* Code blocks use a fixed dark slate theme in both light and dark modes
179
+ (standard "dark editor chrome" convention; no theme token maps to it). */
178
180
  .ProseMirror pre {
179
181
  background: #1e293b;
180
182
  color: #e2e8f0;
@@ -184,7 +186,7 @@ const editorStyles = `
184
186
  margin: 0.75rem 0;
185
187
  }
186
188
  .ProseMirror code {
187
- background: #f1f5f9;
189
+ background: var(--surface-muted);
188
190
  padding: 0.125rem 0.375rem;
189
191
  border-radius: 0.25rem;
190
192
  font-size: 0.875em;
@@ -197,18 +199,18 @@ const editorStyles = `
197
199
  }
198
200
  .ProseMirror hr {
199
201
  border: none;
200
- border-top: 2px solid #e5e7eb;
202
+ border-top: 2px solid var(--bdr);
201
203
  margin: 1.5rem 0;
202
204
  }
203
205
  .ProseMirror p.is-editor-empty:first-child::before {
204
206
  content: attr(data-placeholder);
205
207
  float: left;
206
- color: #9ca3af;
208
+ color: var(--muted);
207
209
  pointer-events: none;
208
210
  height: 0;
209
211
  }
210
212
  .ProseMirror .selectedCell {
211
- background-color: #dbeafe;
213
+ background-color: var(--acc-l);
212
214
  }
213
215
  `
214
216
 
@@ -659,7 +661,7 @@ export function TipTapEditor({
659
661
  className={`border-border bg-card flex-1 overflow-hidden rounded-lg border ${className ?? ''}`}
660
662
  >
661
663
  {collaboration && awareness && (
662
- <div className="bg-card flex items-center justify-between gap-2 border-b border-gray-100 px-2 py-1.5">
664
+ <div className="bg-card border-border flex items-center justify-between gap-2 border-b px-2 py-1.5">
663
665
  <PresenceChips awareness={awareness} status={collabStatus} />
664
666
  <OfflineStatus connection={collabStatus} offline={offlineStatus} />
665
667
  </div>
@@ -43,6 +43,33 @@ export const CONSENT_LABELS: Record<string, string> = {
43
43
  preferences: 'Preferences',
44
44
  }
45
45
 
46
+ /** Design-system location badges (`.loc-head` / `.loc-bs` / `.loc-be`). */
47
+ export const PLACEMENT_COLORS: Record<string, string> = {
48
+ head: 'bg-[var(--acc-l)] text-[var(--acc)]',
49
+ body_open: 'bg-[var(--suc-l)] text-[var(--suc)]',
50
+ body_close: 'bg-[var(--info-l)] text-[var(--info)]',
51
+ }
52
+
53
+ export interface ConsentBadgeColors {
54
+ badge: string
55
+ dot: string
56
+ }
57
+
58
+ /** Neutral "No consent" chip — also the fallback for unknown categories. */
59
+ export const NO_CONSENT_COLORS: ConsentBadgeColors = {
60
+ badge: 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--muted)]',
61
+ dot: 'bg-[var(--muted)]',
62
+ }
63
+
64
+ /** Design-system consent badges (`.cb-*`): tinted chip + colored dot per category. */
65
+ export const CONSENT_COLORS: Record<string, ConsentBadgeColors> = {
66
+ none: NO_CONSENT_COLORS,
67
+ analytics: { badge: 'bg-[var(--acc-l)] text-[var(--acc)]', dot: 'bg-[var(--acc)]' },
68
+ marketing: { badge: 'bg-[var(--err-l)] text-[var(--err)]', dot: 'bg-[var(--err)]' },
69
+ functional: { badge: 'bg-[var(--suc-l)] text-[var(--suc)]', dot: 'bg-[var(--suc)]' },
70
+ preferences: { badge: 'bg-[var(--warn-l)] text-[var(--warn)]', dot: 'bg-[var(--warn)]' },
71
+ }
72
+
46
73
  const PLACEMENT_OPTIONS = [
47
74
  { value: 'head', label: 'Head', hint: '<head>' },
48
75
  { value: 'body_open', label: 'Body start', hint: '<body>' },
@@ -248,10 +275,18 @@ export function ScriptTagEditorPane({ tag, onClose, onChanged }: ScriptTagEditor
248
275
  />
249
276
  </div>
250
277
  <div className="mt-1.5 flex flex-wrap items-center gap-1.5 px-1.5">
251
- <span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-1.5 py-0.5 text-[10.5px] text-[var(--sub)]">
278
+ <span
279
+ className={`rounded-[5px] px-1.5 py-0.5 text-[10.5px] font-medium ${PLACEMENT_COLORS[placement] ?? 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--sub)]'}`}
280
+ >
252
281
  {PLACEMENT_LABELS[placement] ?? placement}
253
282
  </span>
254
- <span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-1.5 py-0.5 text-[10.5px] text-[var(--sub)]">
283
+ <span
284
+ className={`inline-flex items-center gap-[3px] rounded-[5px] px-1.5 py-0.5 text-[10.5px] font-medium ${(CONSENT_COLORS[consentCategory] ?? NO_CONSENT_COLORS).badge}`}
285
+ >
286
+ <span
287
+ className={`inline-block h-[5px] w-[5px] rounded-full ${(CONSENT_COLORS[consentCategory] ?? NO_CONSENT_COLORS).dot}`}
288
+ aria-hidden
289
+ />
255
290
  {CONSENT_LABELS[consentCategory] ?? consentCategory}
256
291
  </span>
257
292
  </div>
@@ -407,8 +442,9 @@ export function ScriptTagEditorPane({ tag, onClose, onChanged }: ScriptTagEditor
407
442
  </div>
408
443
  {consentCategory !== 'none' && (
409
444
  <p className="mt-2 text-[11px] text-[var(--muted)]">
410
- The script is injected only after the visitor accepts cookies (Actuate Consent
411
- &ldquo;Accept all&rdquo;).
445
+ The script is injected only after the visitor grants the{' '}
446
+ {CONSENT_LABELS[consentCategory] ?? consentCategory} consent category (Actuate Consent
447
+ banner).
412
448
  </p>
413
449
  )}
414
450
  </InspectorPaneSection>
package/src/index.ts CHANGED
@@ -50,13 +50,16 @@ export type {
50
50
  } from './layout/primitives/index.js'
51
51
 
52
52
  export { Dashboard } from './views/Dashboard.js'
53
+ /** @deprecated Superseded by `PagesListView` routing in `AdminRoot`; will be removed in the next major. */
53
54
  export { Pages } from './views/Pages.js'
55
+ /** @deprecated Superseded by the visual page builder (`PageBuilder`); will be removed in the next major. */
54
56
  export { PageEditor } from './views/PageEditor.js'
55
57
  export { MediaBrowser } from './views/MediaBrowser.js'
56
58
  export { Forms } from './views/Forms.js'
57
59
  export { FormEditor } from './views/FormEditor.js'
58
60
  export type { FormEditorProps } from './views/FormEditor.js'
59
61
  export { FormSubmissions } from './views/FormSubmissions.js'
62
+ /** @deprecated Superseded by the Redirects tab inside the `SEO` view; will be removed in the next major. */
60
63
  export { Redirects } from './views/Redirects.js'
61
64
  export { Users } from './views/Users.js'
62
65
  export { Settings } from './views/Settings.js'
@@ -520,6 +520,21 @@ export async function duplicatePage(id: string): Promise<{ id?: string; error?:
520
520
  return { id: res.data?.id }
521
521
  }
522
522
 
523
+ /**
524
+ * Reparent a page in the tree. `parentPageId: null` makes it top-level.
525
+ * Document updates merge `data`, so only the parent link changes.
526
+ */
527
+ export async function movePage(
528
+ id: string,
529
+ parentPageId: string | null,
530
+ ): Promise<{ error?: string }> {
531
+ const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
532
+ method: 'PUT',
533
+ body: JSON.stringify({ data: { parentPageId } }),
534
+ })
535
+ return res.error ? { error: res.error } : {}
536
+ }
537
+
523
538
  /** Soft-delete a single page. */
524
539
  export async function deletePage(id: string): Promise<{ error?: string }> {
525
540
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
@@ -143,3 +143,57 @@ export function flattenPageTree(nodes: PageTreeNode[], expanded: Set<string>): P
143
143
  export function defaultExpandedIds(roots: PageTreeNode[]): Set<string> {
144
144
  return new Set(roots.filter((n) => n.children.length > 0).map((n) => n.page.id))
145
145
  }
146
+
147
+ /** Ids of every page in `id`'s subtree (excluding `id` itself). */
148
+ export function collectDescendantIds(roots: PageTreeNode[], id: string): Set<string> {
149
+ const out = new Set<string>()
150
+ const collect = (node: PageTreeNode) => {
151
+ for (const child of node.children) {
152
+ out.add(child.page.id)
153
+ collect(child)
154
+ }
155
+ }
156
+ const find = (nodes: PageTreeNode[]): PageTreeNode | undefined => {
157
+ for (const n of nodes) {
158
+ if (n.page.id === id) return n
159
+ const hit = find(n.children)
160
+ if (hit) return hit
161
+ }
162
+ return undefined
163
+ }
164
+ const node = find(roots)
165
+ if (node) collect(node)
166
+ return out
167
+ }
168
+
169
+ export type ReparentCheck = { ok: true } | { ok: false; reason: string }
170
+
171
+ /**
172
+ * Validate a drag-reparent: `sourceId` becomes a child of `targetId`
173
+ * (or a root when `targetId` is null). Rejects self-drops, drops into the
174
+ * page's own subtree (would create a cycle), and no-op moves.
175
+ */
176
+ export function checkReparent(
177
+ roots: PageTreeNode[],
178
+ sourceId: string,
179
+ targetId: string | null,
180
+ ): ReparentCheck {
181
+ if (targetId === sourceId) {
182
+ return { ok: false, reason: 'A page cannot be its own parent' }
183
+ }
184
+ if (targetId !== null && collectDescendantIds(roots, sourceId).has(targetId)) {
185
+ return { ok: false, reason: 'Cannot move a page into its own sub-pages' }
186
+ }
187
+ const parentOf = (nodes: PageTreeNode[], parent: string | null): string | null | undefined => {
188
+ for (const n of nodes) {
189
+ if (n.page.id === sourceId) return parent
190
+ const hit = parentOf(n.children, n.page.id)
191
+ if (hit !== undefined) return hit
192
+ }
193
+ return undefined
194
+ }
195
+ if ((parentOf(roots, null) ?? null) === targetId) {
196
+ return { ok: false, reason: 'Page is already there' }
197
+ }
198
+ return { ok: true }
199
+ }
@@ -351,11 +351,11 @@ export function FormEditor({ formId, onNavigate }: FormEditorProps) {
351
351
  </label>
352
352
  <div className="flex gap-3">
353
353
  <label
354
- className="hover:bg-muted flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors"
355
- style={{
356
- borderColor: confirmation.type === 'message' ? '#2563eb' : '#d1d5db',
357
- backgroundColor: confirmation.type === 'message' ? '#eff6ff' : 'transparent',
358
- }}
354
+ className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
355
+ confirmation.type === 'message'
356
+ ? 'border-primary bg-accent'
357
+ : 'border-border hover:bg-muted bg-transparent'
358
+ }`}
359
359
  >
360
360
  <input
361
361
  type="radio"
@@ -369,11 +369,11 @@ export function FormEditor({ formId, onNavigate }: FormEditorProps) {
369
369
  <span className="text-sm">Show Message</span>
370
370
  </label>
371
371
  <label
372
- className="hover:bg-muted flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors"
373
- style={{
374
- borderColor: confirmation.type === 'redirect' ? '#2563eb' : '#d1d5db',
375
- backgroundColor: confirmation.type === 'redirect' ? '#eff6ff' : 'transparent',
376
- }}
372
+ className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2 transition-colors ${
373
+ confirmation.type === 'redirect'
374
+ ? 'border-primary bg-accent'
375
+ : 'border-border hover:bg-muted bg-transparent'
376
+ }`}
377
377
  >
378
378
  <input
379
379
  type="radio"
@@ -5,6 +5,12 @@
5
5
  * (`config.admin.loginPath` set). Mimics the framework's default 404 page so
6
6
  * hitting `/admin` reveals nothing — no login form, no admin branding, and no
7
7
  * redirect that would leak the real login location.
8
+ *
9
+ * INTENTIONAL raw colors: although this mounts inside `.actuate-admin` (so the
10
+ * theme CSS vars are technically available), it must NOT use them. Painting
11
+ * this screen with the admin palette (or its dark-mode variant) would make it
12
+ * visibly differ from the framework's stock 404 and fingerprint the hidden
13
+ * admin to anyone probing `/admin`. Plain #fff/#000 matches the default 404.
8
14
  */
9
15
  export function HiddenLoginNotFound() {
10
16
  return (
@@ -31,6 +31,7 @@ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
31
31
  import { useApiData } from '../lib/useApiData.js'
32
32
  import { cmsApi } from '../lib/api.js'
33
33
  import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
34
+ import { Button } from '../components/ui/Button.js'
34
35
  import { StatusBadge } from '../components/ui/StatusBadge.js'
35
36
  import { StatPill } from '../components/ui/StatPill.js'
36
37
  import { MediaSeoAudit, type MediaSeoAuditItem } from './MediaSeoAudit.js'
@@ -508,18 +509,16 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
508
509
  className="hidden"
509
510
  onChange={(e) => handleUploadFiles(e.target.files)}
510
511
  />
511
- <button
512
+ <Button
513
+ type="button"
514
+ variant="primary"
515
+ size="md"
512
516
  onClick={() => fileInputRef.current?.click()}
513
- disabled={uploading}
514
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50"
517
+ loading={uploading}
518
+ leftIcon={<Upload size={16} aria-hidden />}
515
519
  >
516
- {uploading ? (
517
- <Loader2 className="h-4 w-4 animate-spin" />
518
- ) : (
519
- <Upload className="h-4 w-4" />
520
- )}
521
520
  {uploading ? 'Uploading...' : 'Upload Files'}
522
- </button>
521
+ </Button>
523
522
  </div>
524
523
  </div>
525
524