@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
@@ -0,0 +1,271 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Inline redirect editor pane — design-parity replacement for the old
5
+ * `RedirectEditorDrawer` overlay. Rendered inside an `<InspectorPane>` beside
6
+ * the redirect rules table. Same fields, validation, and create/update save
7
+ * paths as the drawer; only the presentation pattern changed.
8
+ */
9
+ import { useState } from 'react'
10
+ import { Trash2 } from 'lucide-react'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ createRedirectRule,
14
+ updateRedirectRule,
15
+ type RedirectRule,
16
+ type RedirectRulePayload,
17
+ type RedirectStatusCode,
18
+ } from '../../lib/seo-service.js'
19
+ import {
20
+ InspectorPaneBody,
21
+ InspectorPaneFooter,
22
+ InspectorPaneHeader,
23
+ InspectorPaneSection,
24
+ } from '../ui/InspectorPane.js'
25
+ import { Toggle } from '../ui/Toggle.js'
26
+
27
+ const STATUS_CODES: { value: RedirectStatusCode; label: string }[] = [
28
+ { value: 301, label: '301 — Permanent' },
29
+ { value: 302, label: '302 — Temporary' },
30
+ { value: 307, label: '307 — Temporary (preserve method)' },
31
+ { value: 308, label: '308 — Permanent (preserve method)' },
32
+ { value: 410, label: '410 — Gone (no destination)' },
33
+ ]
34
+
35
+ const inputCls =
36
+ 'w-full rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[12.5px] text-[var(--txt)] placeholder:text-[var(--muted)] focus:border-[var(--acc)] focus:outline-none'
37
+
38
+ const labelCls = 'mb-1 block text-[11.5px] font-medium text-[var(--sub)]'
39
+
40
+ function normalizePath(p: string): string {
41
+ const t = p.trim()
42
+ if (!t) return ''
43
+ if (/^https?:\/\//i.test(t)) return t
44
+ return t.startsWith('/') ? t : `/${t}`
45
+ }
46
+
47
+ function PaneToggleRow({
48
+ label,
49
+ hint,
50
+ checked,
51
+ onCheckedChange,
52
+ variant,
53
+ }: {
54
+ label: string
55
+ hint?: string
56
+ checked: boolean
57
+ onCheckedChange: (checked: boolean) => void
58
+ variant?: 'enabled'
59
+ }) {
60
+ return (
61
+ <div className="flex items-center justify-between gap-3 py-1.5">
62
+ <div className="min-w-0">
63
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{label}</div>
64
+ {hint && <div className="text-[11px] text-[var(--muted)]">{hint}</div>}
65
+ </div>
66
+ <Toggle
67
+ checked={checked}
68
+ onCheckedChange={onCheckedChange}
69
+ variant={variant}
70
+ aria-label={label}
71
+ />
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export interface RedirectEditorPaneProps {
77
+ /** null = create mode */
78
+ rule: RedirectRule | null
79
+ onClose: () => void
80
+ /** Called after a successful save so the list can refetch / close the pane. */
81
+ onSaved?: () => void
82
+ /** Ask the parent to confirm-and-delete this rule (edit mode only). */
83
+ onDelete?: (rule: RedirectRule) => void
84
+ }
85
+
86
+ export function RedirectEditorPane({ rule, onClose, onSaved, onDelete }: RedirectEditorPaneProps) {
87
+ // The parent keys this pane by rule id, so initializing from props is safe.
88
+ const [form, setForm] = useState<RedirectRulePayload>(() =>
89
+ rule
90
+ ? {
91
+ type: rule.type,
92
+ fromPath: rule.fromPath,
93
+ toPath: rule.toPath,
94
+ enabled: rule.enabled,
95
+ preserveQuery: rule.preserveQuery,
96
+ notes: rule.notes ?? '',
97
+ }
98
+ : {
99
+ type: 301,
100
+ fromPath: '',
101
+ toPath: '',
102
+ enabled: true,
103
+ preserveQuery: false,
104
+ notes: '',
105
+ },
106
+ )
107
+ const [saving, setSaving] = useState(false)
108
+ const [clientError, setClientError] = useState<string | null>(null)
109
+
110
+ const isGone = form.type === 410
111
+ const update = (patch: Partial<RedirectRulePayload>) => setForm((f) => ({ ...f, ...patch }))
112
+
113
+ async function handleSave() {
114
+ const fromPath = normalizePath(form.fromPath)
115
+ const toPath = isGone ? '' : normalizePath(form.toPath)
116
+ if (!fromPath) {
117
+ setClientError('Source path is required.')
118
+ return
119
+ }
120
+ if (!isGone && !toPath) {
121
+ setClientError('Destination is required for redirect status codes.')
122
+ return
123
+ }
124
+ if (!isGone && fromPath === toPath) {
125
+ setClientError('Source and destination cannot be identical (would create a loop).')
126
+ return
127
+ }
128
+ setClientError(null)
129
+ setSaving(true)
130
+ const payload: RedirectRulePayload = { ...form, fromPath, toPath }
131
+ const res = rule
132
+ ? await updateRedirectRule(rule.id, payload)
133
+ : await createRedirectRule(payload)
134
+ setSaving(false)
135
+ if (res.error) {
136
+ toast.error(res.error)
137
+ return
138
+ }
139
+ toast.success(rule ? 'Redirect updated.' : 'Redirect created.')
140
+ onSaved?.()
141
+ }
142
+
143
+ return (
144
+ <>
145
+ <InspectorPaneHeader onClose={onClose} closeLabel="Close redirect editor">
146
+ <div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
147
+ {rule ? 'Edit redirect' : 'New redirect'}
148
+ </div>
149
+ {rule && (
150
+ <div className="mt-0.5 truncate font-mono text-[11.5px] text-[var(--muted)]">
151
+ {rule.fromPath}
152
+ </div>
153
+ )}
154
+ </InspectorPaneHeader>
155
+
156
+ <InspectorPaneBody>
157
+ {clientError && (
158
+ <div className="px-4 pt-3.5">
159
+ <p
160
+ className="rounded-[7px] bg-[var(--err-l)] px-3 py-2 text-[12px] text-[var(--err)]"
161
+ role="alert"
162
+ >
163
+ {clientError}
164
+ </p>
165
+ </div>
166
+ )}
167
+
168
+ <InspectorPaneSection title="Rule">
169
+ <div className="mb-3">
170
+ <label className={labelCls} htmlFor="redir-type">
171
+ Status code
172
+ </label>
173
+ <select
174
+ id="redir-type"
175
+ className={inputCls}
176
+ value={form.type}
177
+ onChange={(e) => update({ type: Number(e.target.value) as RedirectStatusCode })}
178
+ >
179
+ {STATUS_CODES.map((s) => (
180
+ <option key={s.value} value={s.value}>
181
+ {s.label}
182
+ </option>
183
+ ))}
184
+ </select>
185
+ </div>
186
+
187
+ <div className="mb-3">
188
+ <label className={labelCls} htmlFor="redir-from">
189
+ Source path
190
+ </label>
191
+ <input
192
+ id="redir-from"
193
+ className={inputCls}
194
+ value={form.fromPath}
195
+ onChange={(e) => update({ fromPath: e.target.value })}
196
+ placeholder="/old-path"
197
+ />
198
+ </div>
199
+
200
+ {!isGone && (
201
+ <div className="mb-3 last:mb-0">
202
+ <label className={labelCls} htmlFor="redir-to">
203
+ Destination
204
+ </label>
205
+ <input
206
+ id="redir-to"
207
+ className={inputCls}
208
+ value={form.toPath}
209
+ onChange={(e) => update({ toPath: e.target.value })}
210
+ placeholder="/new-path or https://example.com/page"
211
+ />
212
+ </div>
213
+ )}
214
+ </InspectorPaneSection>
215
+
216
+ <InspectorPaneSection title="Options">
217
+ <PaneToggleRow
218
+ label="Enabled"
219
+ checked={!!form.enabled}
220
+ onCheckedChange={(v) => update({ enabled: v })}
221
+ variant="enabled"
222
+ />
223
+ {!isGone && (
224
+ <PaneToggleRow
225
+ label="Preserve query string"
226
+ hint="Append the original query params to the destination."
227
+ checked={!!form.preserveQuery}
228
+ onCheckedChange={(v) => update({ preserveQuery: v })}
229
+ />
230
+ )}
231
+ <div className="mt-2">
232
+ <label className={labelCls} htmlFor="redir-notes">
233
+ Notes <span className="font-normal text-[var(--muted)]">(optional)</span>
234
+ </label>
235
+ <input
236
+ id="redir-notes"
237
+ className={inputCls}
238
+ value={form.notes ?? ''}
239
+ onChange={(e) => update({ notes: e.target.value })}
240
+ placeholder="Why this redirect exists"
241
+ />
242
+ </div>
243
+ </InspectorPaneSection>
244
+ </InspectorPaneBody>
245
+
246
+ <InspectorPaneFooter>
247
+ <div className="flex items-center gap-2">
248
+ {rule && onDelete && (
249
+ <button
250
+ type="button"
251
+ onClick={() => onDelete(rule)}
252
+ disabled={saving}
253
+ aria-label={`Delete redirect ${rule.fromPath}`}
254
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[7px] border border-[var(--bdr)] text-[var(--err)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--err-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-50"
255
+ >
256
+ <Trash2 size={16} aria-hidden />
257
+ </button>
258
+ )}
259
+ <button
260
+ type="button"
261
+ onClick={() => void handleSave()}
262
+ disabled={saving}
263
+ className="bg-primary text-primary-foreground min-w-0 flex-1 rounded-[7px] px-3 py-2 text-[13px] font-semibold transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
264
+ >
265
+ {saving ? 'Saving…' : rule ? 'Save changes' : 'Create redirect'}
266
+ </button>
267
+ </div>
268
+ </InspectorPaneFooter>
269
+ </>
270
+ )
271
+ }
@@ -523,7 +523,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
523
523
  type="button"
524
524
  onClick={() => void handleSave()}
525
525
  disabled={saving || loading || !!error}
526
- className="w-full rounded-[7px] bg-[var(--acc)] px-3 py-2 text-[13px] font-semibold text-white transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
526
+ className="bg-primary text-primary-foreground w-full rounded-[7px] px-3 py-2 text-[13px] font-semibold transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
527
527
  >
528
528
  {saving ? 'Saving…' : 'Save Changes'}
529
529
  </button>
@@ -59,7 +59,7 @@ export function DataTable({
59
59
  return (
60
60
  <div className="overflow-x-auto rounded-lg border border-[var(--border)]">
61
61
  <table className="w-full text-left text-sm">
62
- <thead className="border-b border-[var(--border)] bg-[var(--muted)]">
62
+ <thead className="bg-muted border-b border-[var(--border)]">
63
63
  <tr>
64
64
  <th className="w-10 px-4 py-3">
65
65
  <input
@@ -146,7 +146,7 @@ function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
146
146
  <button
147
147
  key={action.key}
148
148
  onClick={() => action.onClick(row)}
149
- className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
149
+ className="hover:bg-muted rounded p-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
150
150
  aria-label={action.label}
151
151
  title={action.label}
152
152
  >
@@ -155,7 +155,7 @@ function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
155
155
  ))}
156
156
  <button
157
157
  onClick={() => setOpen((o) => !o)}
158
- className="rounded p-1 hover:bg-[var(--muted)]"
158
+ className="hover:bg-muted rounded p-1"
159
159
  aria-label="More actions"
160
160
  >
161
161
  <MoreVerticalIcon />
@@ -10,10 +10,10 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
10
10
  if (variant === 'card') {
11
11
  return (
12
12
  <div className={`animate-pulse rounded-lg border border-[var(--border)] p-5 ${className}`}>
13
- <div className="mb-3 h-4 w-1/3 rounded bg-[var(--muted)]" />
13
+ <div className="bg-muted mb-3 h-4 w-1/3 rounded" />
14
14
  <div className="space-y-2">
15
- <div className="h-3 w-full rounded bg-[var(--muted)]" />
16
- <div className="h-3 w-2/3 rounded bg-[var(--muted)]" />
15
+ <div className="bg-muted h-3 w-full rounded" />
16
+ <div className="bg-muted h-3 w-2/3 rounded" />
17
17
  </div>
18
18
  </div>
19
19
  )
@@ -24,10 +24,10 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
24
24
  <div className={`animate-pulse ${className}`}>
25
25
  {Array.from({ length: lines }, (_, i) => (
26
26
  <div key={i} className="flex gap-4 border-b border-[var(--border)] px-4 py-3">
27
- <div className="h-4 w-8 rounded bg-[var(--muted)]" />
28
- <div className="h-4 flex-1 rounded bg-[var(--muted)]" />
29
- <div className="h-4 w-20 rounded bg-[var(--muted)]" />
30
- <div className="h-4 w-24 rounded bg-[var(--muted)]" />
27
+ <div className="bg-muted h-4 w-8 rounded" />
28
+ <div className="bg-muted h-4 flex-1 rounded" />
29
+ <div className="bg-muted h-4 w-20 rounded" />
30
+ <div className="bg-muted h-4 w-24 rounded" />
31
31
  </div>
32
32
  ))}
33
33
  </div>
@@ -39,7 +39,7 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
39
39
  {Array.from({ length: lines }, (_, i) => (
40
40
  <div
41
41
  key={i}
42
- className="h-3 rounded bg-[var(--muted)]"
42
+ className="bg-muted h-3 rounded"
43
43
  style={{ width: `${Math.max(40, 100 - i * 15)}%` }}
44
44
  />
45
45
  ))}
@@ -26,7 +26,7 @@ const toggleTrack = cv(
26
26
  )
27
27
 
28
28
  const toggleThumb = cv(
29
- 'pointer-events-none block rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-transform duration-[var(--motion-base)]',
29
+ 'pointer-events-none block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-transform duration-[var(--motion-base)]',
30
30
  {
31
31
  variants: {
32
32
  size: {
@@ -114,7 +114,7 @@ function BlockField({ field, value, onChange }: BlockFieldProps) {
114
114
  {field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
115
115
  </label>
116
116
  <div className="flex items-center gap-3 rounded-md border border-dashed border-[var(--border)] p-3">
117
- <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-[var(--muted)]">
117
+ <div className="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-md">
118
118
  <Image className="h-5 w-5 text-[var(--muted-foreground)]" />
119
119
  </div>
120
120
  <span className="text-sm text-[var(--muted-foreground)]">
@@ -18,7 +18,7 @@ export function MediaField({ label, value, onChange, required, helpText }: Media
18
18
 
19
19
  {value ? (
20
20
  <div className="flex items-center gap-3 rounded-md border border-[var(--border)] p-3">
21
- <div className="h-16 w-16 shrink-0 rounded-md bg-[var(--muted)]" />
21
+ <div className="bg-muted h-16 w-16 shrink-0 rounded-md" />
22
22
  <div className="flex-1">
23
23
  <p className="text-sm font-medium">{value.filename}</p>
24
24
  <button
@@ -302,7 +302,7 @@ function LiteralDisplay({
302
302
  return (
303
303
  <div className="text-sm">
304
304
  <div className="mb-1">{label}</div>
305
- <div className="rounded-md border border-dashed border-[var(--border)] bg-[var(--muted)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
305
+ <div className="bg-muted rounded-md border border-dashed border-[var(--border)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
306
306
  Fixed: <code>{String(literal)}</code>
307
307
  </div>
308
308
  </div>
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'
@@ -127,8 +127,8 @@ export function Header({
127
127
  className="hover:bg-accent flex items-center gap-1.5 rounded-lg p-1 transition-colors"
128
128
  aria-label="Account menu"
129
129
  >
130
- <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--acc)]">
131
- <span className="text-xs font-semibold text-white">{initial}</span>
130
+ <div className="bg-primary flex h-8 w-8 items-center justify-center rounded-full">
131
+ <span className="text-primary-foreground text-xs font-semibold">{initial}</span>
132
132
  </div>
133
133
  <ChevronDown
134
134
  className="text-muted-foreground hidden h-4 w-4 sm:block"
@@ -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
+ }
@@ -63,6 +63,10 @@
63
63
  --motion-fast: 0.12s;
64
64
  --motion-base: 0.18s;
65
65
  --motion-layout: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
66
+
67
+ /* Controls — the toggle thumb stays white in both modes per the design
68
+ system (.tog-thumb { background: #fff }), tokenized for themeability. */
69
+ --toggle-thumb: #ffffff;
66
70
  }
67
71
 
68
72
  .dark .actuate-admin,
@@ -19,7 +19,13 @@
19
19
  --primary-foreground: #ffffff;
20
20
  --secondary: var(--acc-l);
21
21
  --secondary-foreground: var(--txt);
22
- --muted: color-mix(in srgb, var(--bdr) 55%, var(--card));
22
+ /*
23
+ * IMPORTANT: never redefine --muted here. The raw design token --muted
24
+ * (actuate-tokens.css) is a faint TEXT color used by design-parity
25
+ * surfaces via text-[var(--muted)]. The muted SURFACE used by the
26
+ * Tailwind bg-muted utility is a separate private token below.
27
+ */
28
+ --surface-muted: color-mix(in srgb, var(--bdr) 55%, var(--card));
23
29
  --muted-foreground: var(--sub);
24
30
  --accent: var(--acc-l);
25
31
  --accent-foreground: var(--acc);
@@ -68,7 +74,7 @@
68
74
  --info-foreground: #0d0c18;
69
75
  --destructive-foreground: #0d0c18;
70
76
  --sidebar-primary-foreground: #0d0c18;
71
- --muted: color-mix(in srgb, var(--bdr) 40%, var(--card));
77
+ --surface-muted: color-mix(in srgb, var(--bdr) 40%, var(--card));
72
78
  }
73
79
 
74
80
  @theme inline {
@@ -82,7 +88,7 @@
82
88
  --color-primary-foreground: var(--primary-foreground);
83
89
  --color-secondary: var(--secondary);
84
90
  --color-secondary-foreground: var(--secondary-foreground);
85
- --color-muted: var(--muted);
91
+ --color-muted: var(--surface-muted);
86
92
  --color-muted-foreground: var(--muted-foreground);
87
93
  --color-accent: var(--accent);
88
94
  --color-accent-foreground: var(--accent-foreground);
@@ -321,7 +321,7 @@ export function DocumentEdit({
321
321
  <div className="flex items-center gap-2">
322
322
  <button
323
323
  onClick={() => setShowPreview((v) => !v)}
324
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
324
+ className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
325
325
  title={showPreview ? 'Hide preview' : 'Show preview'}
326
326
  >
327
327
  {showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@@ -331,7 +331,7 @@ export function DocumentEdit({
331
331
  <>
332
332
  <button
333
333
  onClick={() => setShowShareLink(true)}
334
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
334
+ className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
335
335
  title="Share a signed preview link for the current draft"
336
336
  >
337
337
  <Share2 className="h-4 w-4" />
@@ -339,7 +339,7 @@ export function DocumentEdit({
339
339
  </button>
340
340
  <button
341
341
  onClick={() => setShowVersions(true)}
342
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
342
+ className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
343
343
  title="Version history"
344
344
  >
345
345
  <Clock className="h-4 w-4" />
@@ -347,7 +347,7 @@ export function DocumentEdit({
347
347
  </button>
348
348
  <button
349
349
  onClick={handleDuplicate}
350
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
350
+ className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
351
351
  title="Duplicate this document"
352
352
  >
353
353
  <Copy className="h-4 w-4" />
@@ -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"