@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.
- package/CHANGELOG.md +39 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/RowActions.d.ts +19 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -0
- package/dist/components/ui/RowActions.js +16 -0
- package/dist/components/ui/RowActions.js.map +1 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.js +8 -38
- package/dist/layout/Layout.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +9 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/posts-service.d.ts +11 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +13 -0
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +40 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +55 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +145 -16
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +66 -9
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +15 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +7 -3
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/RowActions.tsx +50 -0
- package/src/components/ui/index.ts +12 -0
- package/src/layout/Header.tsx +2 -2
- package/src/layout/Layout.tsx +8 -80
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-service.ts +18 -0
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/posts-service.ts +26 -0
- package/src/lib/seo-service.ts +3 -0
- package/src/views/Dashboard.tsx +286 -214
- package/src/views/MediaBrowser.tsx +673 -512
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +459 -185
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostsListView.tsx +135 -10
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/TechnicalTab.tsx +15 -0
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- 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
|
+
“Accept all”).
|
|
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'
|
package/src/layout/Header.tsx
CHANGED
|
@@ -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-
|
|
131
|
-
<span className="text-
|
|
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"
|