@actuate-media/cms-admin 0.65.0 → 0.67.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 (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  10. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  12. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  13. package/dist/__tests__/views/dashboard.test.js +7 -3
  14. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  18. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  19. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  22. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  23. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  24. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  27. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  28. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  29. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  30. package/dist/components/ui/InspectorPane.d.ts +51 -0
  31. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  32. package/dist/components/ui/InspectorPane.js +42 -0
  33. package/dist/components/ui/InspectorPane.js.map +1 -0
  34. package/dist/components/ui/RowActions.d.ts +19 -0
  35. package/dist/components/ui/RowActions.d.ts.map +1 -0
  36. package/dist/components/ui/RowActions.js +16 -0
  37. package/dist/components/ui/RowActions.js.map +1 -0
  38. package/dist/components/ui/index.d.ts +4 -0
  39. package/dist/components/ui/index.d.ts.map +1 -1
  40. package/dist/components/ui/index.js +2 -0
  41. package/dist/components/ui/index.js.map +1 -1
  42. package/dist/layout/Header.js +1 -1
  43. package/dist/layout/Header.js.map +1 -1
  44. package/dist/layout/Layout.js +8 -38
  45. package/dist/layout/Layout.js.map +1 -1
  46. package/dist/lib/page-editor-service.d.ts +11 -2
  47. package/dist/lib/page-editor-service.d.ts.map +1 -1
  48. package/dist/lib/page-editor-service.js +8 -2
  49. package/dist/lib/page-editor-service.js.map +1 -1
  50. package/dist/lib/pages-service.d.ts +9 -0
  51. package/dist/lib/pages-service.d.ts.map +1 -1
  52. package/dist/lib/pages-service.js +14 -0
  53. package/dist/lib/pages-service.js.map +1 -1
  54. package/dist/lib/pages-tree.d.ts +44 -0
  55. package/dist/lib/pages-tree.d.ts.map +1 -0
  56. package/dist/lib/pages-tree.js +106 -0
  57. package/dist/lib/pages-tree.js.map +1 -0
  58. package/dist/lib/posts-service.d.ts +11 -0
  59. package/dist/lib/posts-service.d.ts.map +1 -1
  60. package/dist/lib/posts-service.js +13 -0
  61. package/dist/lib/posts-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -0
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/Dashboard.d.ts.map +1 -1
  66. package/dist/views/Dashboard.js +40 -3
  67. package/dist/views/Dashboard.js.map +1 -1
  68. package/dist/views/MediaBrowser.d.ts.map +1 -1
  69. package/dist/views/MediaBrowser.js +55 -2
  70. package/dist/views/MediaBrowser.js.map +1 -1
  71. package/dist/views/MediaSeoAudit.d.ts +25 -0
  72. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  73. package/dist/views/MediaSeoAudit.js +97 -0
  74. package/dist/views/MediaSeoAudit.js.map +1 -0
  75. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  76. package/dist/views/Pages/PagesListView.js +145 -16
  77. package/dist/views/Pages/PagesListView.js.map +1 -1
  78. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  79. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  80. package/dist/views/Pages/PagesTreeTable.js +28 -0
  81. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  82. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  83. package/dist/views/Posts/PostsListView.js +66 -9
  84. package/dist/views/Posts/PostsListView.js.map +1 -1
  85. package/dist/views/ScriptTags.d.ts +5 -1
  86. package/dist/views/ScriptTags.d.ts.map +1 -1
  87. package/dist/views/ScriptTags.js +32 -7
  88. package/dist/views/ScriptTags.js.map +1 -1
  89. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  90. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  91. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  92. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  93. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  94. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  95. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  96. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  97. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  98. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  99. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  100. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  101. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  102. package/dist/views/seo/TechnicalTab.js +15 -0
  103. package/dist/views/seo/TechnicalTab.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/AdminRoot.tsx +7 -3
  106. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  107. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  108. package/src/__tests__/views/dashboard.test.tsx +7 -3
  109. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  110. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  111. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  112. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  113. package/src/components/ui/InspectorPane.tsx +129 -0
  114. package/src/components/ui/RowActions.tsx +50 -0
  115. package/src/components/ui/index.ts +12 -0
  116. package/src/layout/Header.tsx +2 -2
  117. package/src/layout/Layout.tsx +8 -80
  118. package/src/lib/page-editor-service.ts +16 -2
  119. package/src/lib/pages-service.ts +18 -0
  120. package/src/lib/pages-tree.ts +145 -0
  121. package/src/lib/posts-service.ts +26 -0
  122. package/src/lib/seo-service.ts +3 -0
  123. package/src/views/Dashboard.tsx +286 -214
  124. package/src/views/MediaBrowser.tsx +673 -512
  125. package/src/views/MediaSeoAudit.tsx +320 -0
  126. package/src/views/Pages/PagesListView.tsx +459 -185
  127. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  128. package/src/views/Posts/PostsListView.tsx +135 -10
  129. package/src/views/ScriptTags.tsx +204 -129
  130. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  131. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  132. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  133. package/src/views/seo/TechnicalTab.tsx +15 -0
  134. package/dist/views/ScriptTagEditor.d.ts +0 -6
  135. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  136. package/dist/views/ScriptTagEditor.js +0 -111
  137. package/dist/views/ScriptTagEditor.js.map +0 -1
  138. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -0,0 +1,474 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Copy, Minus, Plus, Trash2, X } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import {
7
+ InspectorPaneBody,
8
+ InspectorPaneFooter,
9
+ InspectorPaneHeader,
10
+ InspectorPaneSection,
11
+ } from '../ui/InspectorPane.js'
12
+ import { Toggle } from '../ui/Toggle.js'
13
+ import { ConfirmDialog } from '../ui/ConfirmDialog.js'
14
+ import { cmsApi } from '../../lib/api.js'
15
+
16
+ export interface ScriptTag {
17
+ id: string
18
+ name: string
19
+ code: string
20
+ placement: string
21
+ scope: string
22
+ targetPaths: string[]
23
+ priority: number
24
+ enabled: boolean
25
+ loadAsync: boolean
26
+ disableInPreview: boolean
27
+ consentCategory: string
28
+ createdAt: string
29
+ updatedAt: string
30
+ }
31
+
32
+ export const PLACEMENT_LABELS: Record<string, string> = {
33
+ head: 'Head',
34
+ body_open: 'Body start',
35
+ body_close: 'Body end',
36
+ }
37
+
38
+ export const CONSENT_LABELS: Record<string, string> = {
39
+ none: 'No consent',
40
+ analytics: 'Analytics',
41
+ marketing: 'Marketing',
42
+ functional: 'Functional',
43
+ preferences: 'Preferences',
44
+ }
45
+
46
+ const PLACEMENT_OPTIONS = [
47
+ { value: 'head', label: 'Head', hint: '<head>' },
48
+ { value: 'body_open', label: 'Body start', hint: '<body>' },
49
+ { value: 'body_close', label: 'Body end', hint: '</body>' },
50
+ ]
51
+
52
+ const SCOPE_OPTIONS = [
53
+ { value: 'site', label: 'Entire site' },
54
+ { value: 'parents', label: 'Parent pages' },
55
+ { value: 'urls', label: 'Specific URLs' },
56
+ ]
57
+
58
+ const CONSENT_OPTIONS = ['none', 'analytics', 'marketing', 'functional', 'preferences']
59
+
60
+ function OptionTile({
61
+ active,
62
+ onClick,
63
+ children,
64
+ }: {
65
+ active: boolean
66
+ onClick: () => void
67
+ children: React.ReactNode
68
+ }) {
69
+ return (
70
+ <button
71
+ type="button"
72
+ aria-pressed={active}
73
+ onClick={onClick}
74
+ className={`rounded-[7px] border px-2 py-2 text-[12px] font-medium transition-colors duration-[var(--motion-fast)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
75
+ active
76
+ ? 'border-[var(--acc)] bg-[var(--acc-l)] text-[var(--acc)]'
77
+ : 'border-[var(--bdr)] bg-[var(--card)] text-[var(--sub)] hover:border-[var(--acc-m)]'
78
+ }`}
79
+ >
80
+ {children}
81
+ </button>
82
+ )
83
+ }
84
+
85
+ function ToggleRow({
86
+ label,
87
+ hint,
88
+ checked,
89
+ onCheckedChange,
90
+ }: {
91
+ label: string
92
+ hint: string
93
+ checked: boolean
94
+ onCheckedChange: (checked: boolean) => void
95
+ }) {
96
+ return (
97
+ <div className="flex items-center justify-between gap-3 py-1.5">
98
+ <div className="min-w-0">
99
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">{label}</div>
100
+ <div className="text-[11px] text-[var(--muted)]">{hint}</div>
101
+ </div>
102
+ <Toggle checked={checked} onCheckedChange={onCheckedChange} aria-label={label} />
103
+ </div>
104
+ )
105
+ }
106
+
107
+ export interface ScriptTagEditorPaneProps {
108
+ /** null = create mode */
109
+ tag: ScriptTag | null
110
+ onClose: () => void
111
+ /** Called after any successful save/duplicate/delete so the list can refetch. */
112
+ onChanged: (savedId?: string) => void
113
+ }
114
+
115
+ export function ScriptTagEditorPane({ tag, onClose, onChanged }: ScriptTagEditorPaneProps) {
116
+ const isNew = tag === null
117
+
118
+ const [name, setName] = useState('')
119
+ const [code, setCode] = useState('')
120
+ const [placement, setPlacement] = useState('head')
121
+ const [scope, setScope] = useState('site')
122
+ const [targetPaths, setTargetPaths] = useState<string[]>([])
123
+ const [pathInput, setPathInput] = useState('')
124
+ const [priority, setPriority] = useState(100)
125
+ const [enabled, setEnabled] = useState(true)
126
+ const [loadAsync, setLoadAsync] = useState(false)
127
+ const [disableInPreview, setDisableInPreview] = useState(true)
128
+ const [consentCategory, setConsentCategory] = useState('none')
129
+ const [saving, setSaving] = useState(false)
130
+ const [confirmDelete, setConfirmDelete] = useState(false)
131
+
132
+ useEffect(() => {
133
+ setName(tag?.name ?? '')
134
+ setCode(tag?.code ?? '')
135
+ setPlacement(tag?.placement ?? 'head')
136
+ setScope(tag?.scope ?? 'site')
137
+ setTargetPaths(tag?.targetPaths ?? [])
138
+ setPathInput('')
139
+ setPriority(tag?.priority ?? 100)
140
+ setEnabled(tag?.enabled ?? true)
141
+ setLoadAsync(tag?.loadAsync ?? false)
142
+ setDisableInPreview(tag?.disableInPreview ?? true)
143
+ setConsentCategory(tag?.consentCategory ?? 'none')
144
+ }, [tag])
145
+
146
+ const addPath = () => {
147
+ const trimmed = pathInput.trim()
148
+ if (!trimmed) return
149
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
150
+ if (!targetPaths.includes(normalized)) setTargetPaths([...targetPaths, normalized])
151
+ setPathInput('')
152
+ }
153
+
154
+ const buildPayload = () => ({
155
+ name: name.trim(),
156
+ code,
157
+ placement,
158
+ scope,
159
+ targetPaths: scope === 'site' ? [] : targetPaths,
160
+ priority,
161
+ enabled,
162
+ loadAsync,
163
+ disableInPreview,
164
+ consentCategory,
165
+ })
166
+
167
+ const validate = (): boolean => {
168
+ if (!name.trim()) {
169
+ toast.error('Name is required')
170
+ return false
171
+ }
172
+ if (!code.trim()) {
173
+ toast.error('Code snippet is required')
174
+ return false
175
+ }
176
+ if (scope !== 'site' && targetPaths.length === 0) {
177
+ toast.error('At least one target path is required for this scope')
178
+ return false
179
+ }
180
+ return true
181
+ }
182
+
183
+ const handleSave = async () => {
184
+ if (!validate()) return
185
+ setSaving(true)
186
+ const res = isNew
187
+ ? await cmsApi<ScriptTag>('/script-tags', {
188
+ method: 'POST',
189
+ body: JSON.stringify(buildPayload()),
190
+ })
191
+ : await cmsApi<ScriptTag>(`/script-tags/${tag.id}`, {
192
+ method: 'PUT',
193
+ body: JSON.stringify(buildPayload()),
194
+ })
195
+ setSaving(false)
196
+ if (res.error) {
197
+ toast.error(res.error)
198
+ return
199
+ }
200
+ toast.success(isNew ? 'Script created' : 'Script saved')
201
+ onChanged(res.data?.id ?? tag?.id)
202
+ }
203
+
204
+ const handleDuplicate = async () => {
205
+ if (!tag) return
206
+ const res = await cmsApi<ScriptTag>('/script-tags', {
207
+ method: 'POST',
208
+ body: JSON.stringify({ ...buildPayload(), name: `${name.trim()} (Copy)`, enabled: false }),
209
+ })
210
+ if (res.error) {
211
+ toast.error(res.error)
212
+ return
213
+ }
214
+ toast.success('Script duplicated (created disabled)')
215
+ onChanged(res.data?.id)
216
+ }
217
+
218
+ const handleDelete = async () => {
219
+ if (!tag) return
220
+ const res = await cmsApi(`/script-tags/${tag.id}`, { method: 'DELETE' })
221
+ setConfirmDelete(false)
222
+ if (res.error) {
223
+ toast.error(res.error)
224
+ return
225
+ }
226
+ toast.success('Script deleted')
227
+ onChanged()
228
+ onClose()
229
+ }
230
+
231
+ return (
232
+ <>
233
+ <InspectorPaneHeader onClose={onClose} closeLabel="Close script editor">
234
+ <div className="flex items-center gap-2.5">
235
+ <input
236
+ type="text"
237
+ value={name}
238
+ onChange={(e) => setName(e.target.value)}
239
+ placeholder="Script name…"
240
+ aria-label="Script name"
241
+ className="min-w-0 flex-1 rounded-[7px] border border-transparent bg-transparent px-1.5 py-1 text-[14px] font-semibold text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--bdr)] focus:border-[var(--acc)] focus:outline-none"
242
+ />
243
+ <Toggle
244
+ checked={enabled}
245
+ onCheckedChange={setEnabled}
246
+ variant="enabled"
247
+ aria-label={enabled ? 'Deactivate script' : 'Activate script'}
248
+ />
249
+ </div>
250
+ <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)]">
252
+ {PLACEMENT_LABELS[placement] ?? placement}
253
+ </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)]">
255
+ {CONSENT_LABELS[consentCategory] ?? consentCategory}
256
+ </span>
257
+ </div>
258
+ </InspectorPaneHeader>
259
+
260
+ <InspectorPaneBody>
261
+ <InspectorPaneSection title="Code" subtitle="HTML / JS">
262
+ <textarea
263
+ value={code}
264
+ onChange={(e) => setCode(e.target.value)}
265
+ placeholder="<!-- Paste your script here -->"
266
+ rows={8}
267
+ spellCheck={false}
268
+ aria-label="Script code"
269
+ className="w-full rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-2 font-mono text-[11.5px] text-[var(--txt)] focus:border-[var(--acc)] focus:outline-none"
270
+ />
271
+ <p className="mt-1 text-[11px] text-[var(--muted)]">
272
+ Fires on frontend only — never injected during CMS rendering.
273
+ </p>
274
+ </InspectorPaneSection>
275
+
276
+ <InspectorPaneSection title="Injection Location">
277
+ <div className="grid grid-cols-3 gap-1.5" role="group" aria-label="Injection location">
278
+ {PLACEMENT_OPTIONS.map((opt) => (
279
+ <OptionTile
280
+ key={opt.value}
281
+ active={placement === opt.value}
282
+ onClick={() => setPlacement(opt.value)}
283
+ >
284
+ <span className="block">{opt.label}</span>
285
+ <span className="block font-mono text-[10px] font-normal text-[var(--muted)]">
286
+ {opt.hint}
287
+ </span>
288
+ </OptionTile>
289
+ ))}
290
+ </div>
291
+ <div className="mt-3 flex items-center justify-between gap-3">
292
+ <div>
293
+ <div className="text-[12.5px] font-medium text-[var(--txt)]">Load Priority</div>
294
+ <div className="text-[11px] text-[var(--muted)]">Lower = fires first</div>
295
+ </div>
296
+ <div className="flex items-center gap-1">
297
+ <button
298
+ type="button"
299
+ aria-label="Decrease priority"
300
+ onClick={() => setPriority((p) => Math.max(0, p - 1))}
301
+ className="flex h-[27px] w-[27px] items-center justify-center rounded-[6px] border border-[var(--bdr)] bg-[var(--card)] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
302
+ >
303
+ <Minus size={13} />
304
+ </button>
305
+ <input
306
+ type="number"
307
+ value={priority}
308
+ min={0}
309
+ onChange={(e) => setPriority(Math.max(0, parseInt(e.target.value, 10) || 0))}
310
+ aria-label="Load priority"
311
+ className="w-14 rounded-[6px] border border-[var(--bdr)] bg-[var(--card)] px-1.5 py-1 text-center font-mono text-[12px] text-[var(--txt)] focus:border-[var(--acc)] focus:outline-none"
312
+ />
313
+ <button
314
+ type="button"
315
+ aria-label="Increase priority"
316
+ onClick={() => setPriority((p) => p + 1)}
317
+ className="flex h-[27px] w-[27px] items-center justify-center rounded-[6px] border border-[var(--bdr)] bg-[var(--card)] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
318
+ >
319
+ <Plus size={13} />
320
+ </button>
321
+ </div>
322
+ </div>
323
+ </InspectorPaneSection>
324
+
325
+ <InspectorPaneSection title="Firing Conditions">
326
+ <div className="grid grid-cols-3 gap-1.5" role="group" aria-label="Scope">
327
+ {SCOPE_OPTIONS.map((opt) => (
328
+ <OptionTile
329
+ key={opt.value}
330
+ active={scope === opt.value}
331
+ onClick={() => setScope(opt.value)}
332
+ >
333
+ {opt.label}
334
+ </OptionTile>
335
+ ))}
336
+ </div>
337
+ {scope !== 'site' && (
338
+ <div className="mt-3">
339
+ <label
340
+ htmlFor="script-tag-path-input"
341
+ className="mb-1 block text-[11.5px] font-medium text-[var(--sub)]"
342
+ >
343
+ {scope === 'parents' ? 'Parent paths (includes child pages)' : 'URL paths'}
344
+ </label>
345
+ <div className="flex gap-1.5">
346
+ <input
347
+ id="script-tag-path-input"
348
+ type="text"
349
+ value={pathInput}
350
+ onChange={(e) => setPathInput(e.target.value)}
351
+ onKeyDown={(e) => {
352
+ if (e.key === 'Enter') {
353
+ e.preventDefault()
354
+ addPath()
355
+ }
356
+ }}
357
+ placeholder="/services"
358
+ className="min-w-0 flex-1 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 font-mono text-[12px] text-[var(--txt)] focus:border-[var(--acc)] focus:outline-none"
359
+ />
360
+ <button
361
+ type="button"
362
+ onClick={addPath}
363
+ aria-label="Add path"
364
+ className="flex h-[31px] w-[31px] shrink-0 items-center justify-center rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
365
+ >
366
+ <Plus size={14} />
367
+ </button>
368
+ </div>
369
+ {targetPaths.length > 0 && (
370
+ <div className="mt-2 flex flex-wrap gap-1.5">
371
+ {targetPaths.map((p) => (
372
+ <span
373
+ key={p}
374
+ className="inline-flex items-center gap-1 rounded-[var(--r-pill)] border border-[var(--bdr)] bg-[var(--bg)] px-2 py-0.5 font-mono text-[11px] text-[var(--sub)]"
375
+ >
376
+ {p}
377
+ <button
378
+ type="button"
379
+ onClick={() => setTargetPaths(targetPaths.filter((x) => x !== p))}
380
+ aria-label={`Remove ${p}`}
381
+ className="text-[var(--muted)] hover:text-[var(--err)]"
382
+ >
383
+ <X size={11} />
384
+ </button>
385
+ </span>
386
+ ))}
387
+ </div>
388
+ )}
389
+ </div>
390
+ )}
391
+ </InspectorPaneSection>
392
+
393
+ <InspectorPaneSection title="Cookie Consent" subtitle="GDPR / CCPA">
394
+ <div className="mb-1.5 text-[12.5px] font-medium text-[var(--txt)]">
395
+ Consent category required
396
+ </div>
397
+ <div className="grid grid-cols-3 gap-1.5" role="group" aria-label="Consent category">
398
+ {CONSENT_OPTIONS.map((value) => (
399
+ <OptionTile
400
+ key={value}
401
+ active={consentCategory === value}
402
+ onClick={() => setConsentCategory(value)}
403
+ >
404
+ {CONSENT_LABELS[value]}
405
+ </OptionTile>
406
+ ))}
407
+ </div>
408
+ {consentCategory !== 'none' && (
409
+ <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;).
412
+ </p>
413
+ )}
414
+ </InspectorPaneSection>
415
+
416
+ <InspectorPaneSection title="Advanced">
417
+ <ToggleRow
418
+ label="Load async"
419
+ hint="Non-blocking — doesn't delay page render"
420
+ checked={loadAsync}
421
+ onCheckedChange={setLoadAsync}
422
+ />
423
+ <ToggleRow
424
+ label="Disable in preview mode"
425
+ hint="Skip firing inside CMS page preview"
426
+ checked={disableInPreview}
427
+ onCheckedChange={setDisableInPreview}
428
+ />
429
+ </InspectorPaneSection>
430
+ </InspectorPaneBody>
431
+
432
+ <InspectorPaneFooter>
433
+ <button
434
+ type="button"
435
+ onClick={() => void handleSave()}
436
+ disabled={saving}
437
+ 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"
438
+ >
439
+ {saving ? 'Saving…' : 'Save Script'}
440
+ </button>
441
+ {!isNew && (
442
+ <div className="mt-2 flex items-center justify-center gap-4">
443
+ <button
444
+ type="button"
445
+ onClick={() => void handleDuplicate()}
446
+ className="inline-flex items-center gap-1.5 text-[12px] text-[var(--sub)] transition-colors duration-[var(--motion-fast)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
447
+ >
448
+ <Copy size={12} />
449
+ Duplicate
450
+ </button>
451
+ <button
452
+ type="button"
453
+ onClick={() => setConfirmDelete(true)}
454
+ className="inline-flex items-center gap-1.5 text-[12px] text-[var(--sub)] transition-colors duration-[var(--motion-fast)] hover:text-[var(--err)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
455
+ >
456
+ <Trash2 size={12} />
457
+ Delete
458
+ </button>
459
+ </div>
460
+ )}
461
+ </InspectorPaneFooter>
462
+
463
+ <ConfirmDialog
464
+ open={confirmDelete}
465
+ title="Delete script?"
466
+ description={`"${name || 'This script'}" will stop firing on your site immediately.`}
467
+ confirmLabel="Delete"
468
+ destructive
469
+ onConfirm={() => void handleDelete()}
470
+ onClose={() => setConfirmDelete(false)}
471
+ />
472
+ </>
473
+ )
474
+ }
@@ -0,0 +1,129 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+ import { X } from 'lucide-react'
5
+
6
+ /**
7
+ * Design-system inline split-pane pattern: a list that shrinks while a
8
+ * fixed-width editor/detail pane slides in beside it (Script Tags 420px,
9
+ * Entries 400px, Forms 390px, SEO Content 380px in the handoff).
10
+ *
11
+ * Compose as:
12
+ *
13
+ * <SplitPaneLayout>
14
+ * <SplitPaneList>…table…</SplitPaneList>
15
+ * <InspectorPane open={!!selected} width={420} aria-label="Edit script">
16
+ * <InspectorPaneHeader onClose={close}>…</InspectorPaneHeader>
17
+ * <InspectorPaneBody>…sections…</InspectorPaneBody>
18
+ * <InspectorPaneFooter>…save…</InspectorPaneFooter>
19
+ * </InspectorPane>
20
+ * </SplitPaneLayout>
21
+ */
22
+ export function SplitPaneLayout({
23
+ children,
24
+ className,
25
+ }: {
26
+ children: ReactNode
27
+ className?: string
28
+ }) {
29
+ return <div className={`flex min-h-0 flex-1 overflow-hidden ${className ?? ''}`}>{children}</div>
30
+ }
31
+
32
+ export function SplitPaneList({
33
+ children,
34
+ className,
35
+ }: {
36
+ children: ReactNode
37
+ className?: string
38
+ }) {
39
+ return <div className={`min-w-0 flex-1 overflow-y-auto ${className ?? ''}`}>{children}</div>
40
+ }
41
+
42
+ export interface InspectorPaneProps {
43
+ open: boolean
44
+ /** Outer shell width in px when open (inner card fills it minus 12px gutters). */
45
+ width: number
46
+ children: ReactNode
47
+ 'aria-label': string
48
+ }
49
+
50
+ export function InspectorPane({
51
+ open,
52
+ width,
53
+ children,
54
+ 'aria-label': ariaLabel,
55
+ }: InspectorPaneProps) {
56
+ return (
57
+ <div
58
+ className="shrink-0 overflow-hidden bg-[var(--bg)] motion-safe:transition-[width] motion-safe:duration-200"
59
+ style={{ width: open ? width : 0 }}
60
+ aria-hidden={!open}
61
+ >
62
+ <div
63
+ role="region"
64
+ aria-label={ariaLabel}
65
+ className="my-3 mr-3 ml-4 flex h-[calc(100%-24px)] flex-col overflow-hidden rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--sb)] shadow-[var(--shd2)]"
66
+ style={{ width: width - 28 }}
67
+ >
68
+ {open ? children : null}
69
+ </div>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ export function InspectorPaneHeader({
75
+ children,
76
+ onClose,
77
+ closeLabel = 'Close panel',
78
+ }: {
79
+ children: ReactNode
80
+ onClose: () => void
81
+ closeLabel?: string
82
+ }) {
83
+ return (
84
+ <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-4 py-3.5">
85
+ <div className="min-w-0 flex-1">{children}</div>
86
+ <button
87
+ type="button"
88
+ onClick={onClose}
89
+ aria-label={closeLabel}
90
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--bg)] hover:text-[var(--txt)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
91
+ >
92
+ <X size={16} />
93
+ </button>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ export function InspectorPaneBody({ children }: { children: ReactNode }) {
99
+ return <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
100
+ }
101
+
102
+ /** Bordered section with the design's uppercase 10.5px section title. */
103
+ export function InspectorPaneSection({
104
+ title,
105
+ subtitle,
106
+ children,
107
+ }: {
108
+ title?: string
109
+ subtitle?: string
110
+ children: ReactNode
111
+ }) {
112
+ return (
113
+ <div className="border-b border-[var(--bdr)] px-4 py-3.5 last:border-b-0">
114
+ {title && (
115
+ <div className="mb-2.5 flex items-baseline gap-2">
116
+ <span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
117
+ {title}
118
+ </span>
119
+ {subtitle && <span className="text-[11px] text-[var(--muted)]">{subtitle}</span>}
120
+ </div>
121
+ )}
122
+ {children}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ export function InspectorPaneFooter({ children }: { children: ReactNode }) {
128
+ return <div className="border-t border-[var(--bdr)] px-4 py-3.5">{children}</div>
129
+ }
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
4
+
5
+ /**
6
+ * Design-system table row actions (`.row-actions` / `.ra-icon-btn`): a
7
+ * right-aligned cluster of 27px bordered icon buttons that fades in on row
8
+ * hover (the row must have the `group` class) and on keyboard focus.
9
+ */
10
+ export function RowActions({ children, className }: { children: ReactNode; className?: string }) {
11
+ return (
12
+ <div
13
+ className={`flex items-center justify-end gap-1 opacity-0 transition-opacity duration-[var(--motion-fast)] group-hover:opacity-100 focus-within:opacity-100 ${className ?? ''}`}
14
+ >
15
+ {children}
16
+ </div>
17
+ )
18
+ }
19
+
20
+ export interface RowActionButtonProps extends Omit<
21
+ ButtonHTMLAttributes<HTMLButtonElement>,
22
+ 'type'
23
+ > {
24
+ /** Accessible label — required, these are icon-only buttons. */
25
+ 'aria-label': string
26
+ /** Red hover treatment for destructive actions. */
27
+ danger?: boolean
28
+ children: ReactNode
29
+ }
30
+
31
+ export function RowActionButton({
32
+ danger = false,
33
+ className,
34
+ children,
35
+ ...rest
36
+ }: RowActionButtonProps) {
37
+ return (
38
+ <button
39
+ type="button"
40
+ className={`flex h-[27px] w-[27px] items-center justify-center rounded-[6px] border border-[var(--bdr)] bg-[var(--card)] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
41
+ danger
42
+ ? 'hover:border-[var(--err)] hover:bg-[var(--err-l)] hover:text-[var(--err)]'
43
+ : 'hover:border-[var(--acc-m)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)]'
44
+ } ${className ?? ''}`}
45
+ {...rest}
46
+ >
47
+ {children}
48
+ </button>
49
+ )
50
+ }
@@ -42,6 +42,18 @@ export { FilterPill } from './FilterPill.js'
42
42
  export type { FilterPillProps } from './FilterPill.js'
43
43
  export { StatPill } from './StatPill.js'
44
44
  export type { StatPillProps } from './StatPill.js'
45
+ export { RowActions, RowActionButton } from './RowActions.js'
46
+ export type { RowActionButtonProps } from './RowActions.js'
47
+ export {
48
+ SplitPaneLayout,
49
+ SplitPaneList,
50
+ InspectorPane,
51
+ InspectorPaneHeader,
52
+ InspectorPaneBody,
53
+ InspectorPaneSection,
54
+ InspectorPaneFooter,
55
+ } from './InspectorPane.js'
56
+ export type { InspectorPaneProps } from './InspectorPane.js'
45
57
  export { formControlBase, formControlState } from './form-control.js'
46
58
  export { CommandPalette } from './CommandPalette.js'
47
59
  export type { CommandPaletteProps } from './CommandPalette.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-linear-to-br from-blue-500 to-purple-600">
131
- <span className="text-sm font-medium text-white">{initial}</span>
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>
132
132
  </div>
133
133
  <ChevronDown
134
134
  className="text-muted-foreground hidden h-4 w-4 sm:block"