@actuate-media/cms-admin 0.69.1 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-tree.test.js +40 -1
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +21 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
- package/dist/__tests__/views/script-tags.render.test.d.ts +2 -0
- package/dist/__tests__/views/script-tags.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/script-tags.render.test.js +148 -0
- package/dist/__tests__/views/script-tags.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/TipTapEditor.d.ts.map +1 -1
- package/dist/components/TipTapEditor.js +11 -9
- package/dist/components/TipTapEditor.js.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +10 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js +21 -2
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/pages-service.d.ts +7 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +11 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +14 -0
- package/dist/lib/pages-tree.d.ts.map +1 -1
- package/dist/lib/pages-tree.js +51 -0
- package/dist/lib/pages-tree.js.map +1 -1
- package/dist/views/FormEditor.js +5 -7
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/HiddenLoginNotFound.d.ts +6 -0
- package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
- package/dist/views/HiddenLoginNotFound.js +6 -0
- package/dist/views/HiddenLoginNotFound.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +2 -1
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.d.ts +4 -0
- package/dist/views/PageEditor.d.ts.map +1 -1
- package/dist/views/PageEditor.js +4 -0
- package/dist/views/PageEditor.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +40 -5
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +40 -9
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/Pages.d.ts +4 -0
- package/dist/views/Pages.d.ts.map +1 -1
- package/dist/views/Pages.js +4 -0
- package/dist/views/Pages.js.map +1 -1
- package/dist/views/Redirects.d.ts +4 -0
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +4 -0
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +122 -15
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/page-builder/PageBuilder.d.ts +3 -1
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +24 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +30 -4
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +11 -1
- package/src/__tests__/lib/pages-tree.test.ts +49 -0
- package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
- package/src/__tests__/views/page-templates.render.test.tsx +40 -0
- package/src/__tests__/views/script-tags.render.test.tsx +178 -0
- package/src/components/TipTapEditor.tsx +11 -9
- package/src/components/script-tags/ScriptTagEditorPane.tsx +40 -4
- package/src/index.ts +3 -0
- package/src/lib/pages-service.ts +15 -0
- package/src/lib/pages-tree.ts +54 -0
- package/src/views/FormEditor.tsx +10 -10
- package/src/views/HiddenLoginNotFound.tsx +6 -0
- package/src/views/MediaBrowser.tsx +8 -9
- package/src/views/PageEditor.tsx +4 -0
- package/src/views/Pages/PagesListView.tsx +54 -9
- package/src/views/Pages/PagesTreeTable.tsx +203 -111
- package/src/views/Pages.tsx +4 -0
- package/src/views/Redirects.tsx +4 -0
- package/src/views/ScriptTags.tsx +268 -30
- package/src/views/page-builder/PageBuilder.tsx +30 -2
- package/src/views/page-builder/PageTemplates.tsx +56 -15
package/src/views/ScriptTags.tsx
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState } from 'react'
|
|
4
|
-
import { Code2, Plus, Loader2, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { Code2, Copy, Plus, Loader2, AlertTriangle, Shield, Trash2 } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { EnabledSwitch } from '../components/EnabledSwitch.js'
|
|
7
|
+
import { Button } from '../components/ui/Button.js'
|
|
8
|
+
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
9
|
+
import { FilterPill } from '../components/ui/FilterPill.js'
|
|
10
|
+
import { RowActions, RowActionButton } from '../components/ui/RowActions.js'
|
|
11
|
+
import { SearchInput } from '../components/ui/SearchInput.js'
|
|
7
12
|
import { StatPill } from '../components/ui/StatPill.js'
|
|
8
13
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
9
14
|
import {
|
|
15
|
+
CONSENT_COLORS,
|
|
10
16
|
CONSENT_LABELS,
|
|
17
|
+
NO_CONSENT_COLORS,
|
|
18
|
+
PLACEMENT_COLORS,
|
|
11
19
|
PLACEMENT_LABELS,
|
|
12
20
|
ScriptTagEditorPane,
|
|
13
21
|
type ScriptTag,
|
|
@@ -23,11 +31,21 @@ export interface ScriptTagsProps {
|
|
|
23
31
|
initialNew?: boolean
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
34
|
+
const LOCATION_FILTERS: Array<{ value: string; label: string }> = [
|
|
35
|
+
{ value: 'all', label: 'All' },
|
|
36
|
+
{ value: 'head', label: '<head>' },
|
|
37
|
+
{ value: 'body_open', label: 'Body start' },
|
|
38
|
+
{ value: 'body_close', label: 'Body end' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const CONSENT_FILTERS: Array<{ value: string; label: string }> = [
|
|
42
|
+
{ value: 'all', label: 'All consent' },
|
|
43
|
+
{ value: 'none', label: 'No consent' },
|
|
44
|
+
{ value: 'analytics', label: 'Analytics' },
|
|
45
|
+
{ value: 'marketing', label: 'Marketing' },
|
|
46
|
+
{ value: 'functional', label: 'Functional' },
|
|
47
|
+
{ value: 'preferences', label: 'Preferences' },
|
|
48
|
+
]
|
|
31
49
|
|
|
32
50
|
function normalizeEnabled(value: unknown): boolean {
|
|
33
51
|
if (typeof value === 'boolean') return value
|
|
@@ -51,18 +69,27 @@ function scopeLabel(tag: ScriptTag): string {
|
|
|
51
69
|
return scope || 'Custom'
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
function
|
|
72
|
+
function formatDate(value: string): string {
|
|
55
73
|
const date = new Date(value)
|
|
56
74
|
if (Number.isNaN(date.getTime())) return '—'
|
|
57
75
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
58
76
|
}
|
|
59
77
|
|
|
78
|
+
function consentCategoryOf(tag: ScriptTag): string {
|
|
79
|
+
return tag.consentCategory && tag.consentCategory !== '' ? tag.consentCategory : 'none'
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
type Selection = { mode: 'edit'; id: string } | { mode: 'new' } | null
|
|
61
83
|
|
|
62
84
|
export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
63
85
|
const { data, loading, error, refetch } = useApiData<ScriptTag[]>('/script-tags')
|
|
64
86
|
const [togglingId, setTogglingId] = useState<string | null>(null)
|
|
65
87
|
const [enabledOverrides, setEnabledOverrides] = useState<Record<string, boolean>>({})
|
|
88
|
+
const [search, setSearch] = useState('')
|
|
89
|
+
const [locationFilter, setLocationFilter] = useState('all')
|
|
90
|
+
const [consentFilter, setConsentFilter] = useState('all')
|
|
91
|
+
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
|
92
|
+
const [pendingDelete, setPendingDelete] = useState<ScriptTag | null>(null)
|
|
66
93
|
const [selection, setSelection] = useState<Selection>(
|
|
67
94
|
initialNew
|
|
68
95
|
? { mode: 'new' }
|
|
@@ -80,6 +107,20 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
80
107
|
[data, enabledOverrides],
|
|
81
108
|
)
|
|
82
109
|
|
|
110
|
+
const filteredTags = useMemo(() => {
|
|
111
|
+
const query = search.trim().toLowerCase()
|
|
112
|
+
return tags.filter((tag) => {
|
|
113
|
+
if (locationFilter !== 'all' && tag.placement !== locationFilter) return false
|
|
114
|
+
if (consentFilter !== 'all' && consentCategoryOf(tag) !== consentFilter) return false
|
|
115
|
+
if (query && !tag.name.toLowerCase().includes(query)) return false
|
|
116
|
+
return true
|
|
117
|
+
})
|
|
118
|
+
}, [tags, locationFilter, consentFilter, search])
|
|
119
|
+
|
|
120
|
+
const consentGatedCount = tags.filter((t) => consentCategoryOf(t) !== 'none').length
|
|
121
|
+
const checkedVisible = filteredTags.filter((t) => checkedIds.has(t.id))
|
|
122
|
+
const allVisibleChecked = filteredTags.length > 0 && checkedVisible.length === filteredTags.length
|
|
123
|
+
|
|
83
124
|
const selectedTag =
|
|
84
125
|
selection?.mode === 'edit' ? (tags.find((t) => t.id === selection.id) ?? null) : null
|
|
85
126
|
const paneOpen = selection?.mode === 'new' || selectedTag !== null
|
|
@@ -90,6 +131,25 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
90
131
|
)
|
|
91
132
|
}
|
|
92
133
|
|
|
134
|
+
const toggleChecked = (id: string) => {
|
|
135
|
+
setCheckedIds((prev) => {
|
|
136
|
+
const next = new Set(prev)
|
|
137
|
+
if (next.has(id)) next.delete(id)
|
|
138
|
+
else next.add(id)
|
|
139
|
+
return next
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const toggleAllChecked = () => {
|
|
144
|
+
setCheckedIds(allVisibleChecked ? new Set() : new Set(filteredTags.map((t) => t.id)))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const clearFilters = () => {
|
|
148
|
+
setSearch('')
|
|
149
|
+
setLocationFilter('all')
|
|
150
|
+
setConsentFilter('all')
|
|
151
|
+
}
|
|
152
|
+
|
|
93
153
|
const toggleEnabled = async (tag: ScriptTag, nextEnabled: boolean) => {
|
|
94
154
|
setEnabledOverrides((prev) => ({ ...prev, [tag.id]: nextEnabled }))
|
|
95
155
|
setTogglingId(tag.id)
|
|
@@ -116,6 +176,48 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
116
176
|
toast.success(saved ? 'Tag enabled' : 'Tag disabled')
|
|
117
177
|
}
|
|
118
178
|
|
|
179
|
+
const duplicateTag = async (tag: ScriptTag) => {
|
|
180
|
+
const res = await cmsApi<ScriptTag>('/script-tags', {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
name: `${tag.name} (Copy)`,
|
|
184
|
+
code: tag.code,
|
|
185
|
+
placement: tag.placement,
|
|
186
|
+
scope: tag.scope,
|
|
187
|
+
targetPaths: tag.targetPaths,
|
|
188
|
+
priority: tag.priority,
|
|
189
|
+
enabled: false,
|
|
190
|
+
loadAsync: tag.loadAsync,
|
|
191
|
+
disableInPreview: tag.disableInPreview,
|
|
192
|
+
consentCategory: tag.consentCategory,
|
|
193
|
+
}),
|
|
194
|
+
})
|
|
195
|
+
if (res.error) {
|
|
196
|
+
toast.error(res.error)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
toast.success('Script duplicated (created disabled)')
|
|
200
|
+
refetch()
|
|
201
|
+
if (res.data?.id) setSelection({ mode: 'edit', id: res.data.id })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const deleteTag = async (tag: ScriptTag) => {
|
|
205
|
+
setPendingDelete(null)
|
|
206
|
+
const res = await cmsApi(`/script-tags/${tag.id}`, { method: 'DELETE' })
|
|
207
|
+
if (res.error) {
|
|
208
|
+
toast.error(res.error)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
toast.success('Script deleted')
|
|
212
|
+
setCheckedIds((prev) => {
|
|
213
|
+
const next = new Set(prev)
|
|
214
|
+
next.delete(tag.id)
|
|
215
|
+
return next
|
|
216
|
+
})
|
|
217
|
+
if (selection?.mode === 'edit' && selection.id === tag.id) setSelection(null)
|
|
218
|
+
refetch()
|
|
219
|
+
}
|
|
220
|
+
|
|
119
221
|
const handlePaneChanged = (savedId?: string) => {
|
|
120
222
|
setEnabledOverrides({})
|
|
121
223
|
refetch()
|
|
@@ -150,7 +252,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
150
252
|
<div>
|
|
151
253
|
<h1 className="mb-1 text-[var(--txt)]">Script Tags</h1>
|
|
152
254
|
<p className="text-sm text-[var(--sub)]">
|
|
153
|
-
|
|
255
|
+
Inject scripts into the frontend — never into CMS rendering
|
|
154
256
|
</p>
|
|
155
257
|
{tags.length > 0 && (
|
|
156
258
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
@@ -165,18 +267,70 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
165
267
|
label="Inactive"
|
|
166
268
|
valueClassName="text-[var(--muted)]"
|
|
167
269
|
/>
|
|
270
|
+
<StatPill
|
|
271
|
+
value={consentGatedCount}
|
|
272
|
+
label="Consent-gated"
|
|
273
|
+
icon={<Shield size={12} className="text-[var(--acc)]" aria-hidden />}
|
|
274
|
+
/>
|
|
168
275
|
</div>
|
|
169
276
|
)}
|
|
170
277
|
</div>
|
|
171
|
-
<
|
|
278
|
+
<Button
|
|
172
279
|
type="button"
|
|
280
|
+
variant="primary"
|
|
281
|
+
size="md"
|
|
173
282
|
onClick={() => setSelection({ mode: 'new' })}
|
|
174
|
-
|
|
283
|
+
leftIcon={<Plus size={16} aria-hidden />}
|
|
175
284
|
>
|
|
176
|
-
<Plus className="h-4 w-4" />
|
|
177
285
|
New Script
|
|
178
|
-
</
|
|
286
|
+
</Button>
|
|
179
287
|
</div>
|
|
288
|
+
|
|
289
|
+
{tags.length > 0 && (
|
|
290
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
291
|
+
<div className="w-full max-w-[260px]">
|
|
292
|
+
<SearchInput value={search} onChange={setSearch} placeholder="Search scripts…" />
|
|
293
|
+
</div>
|
|
294
|
+
<div className="flex gap-1" role="group" aria-label="Filter by location">
|
|
295
|
+
{LOCATION_FILTERS.map((f) => (
|
|
296
|
+
<FilterPill
|
|
297
|
+
key={f.value}
|
|
298
|
+
active={locationFilter === f.value}
|
|
299
|
+
onClick={() => setLocationFilter(f.value)}
|
|
300
|
+
>
|
|
301
|
+
{f.label}
|
|
302
|
+
</FilterPill>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
<div className="ml-2 flex flex-wrap gap-1" role="group" aria-label="Filter by consent">
|
|
306
|
+
{CONSENT_FILTERS.map((f) => (
|
|
307
|
+
<FilterPill
|
|
308
|
+
key={f.value}
|
|
309
|
+
active={consentFilter === f.value}
|
|
310
|
+
onClick={() => setConsentFilter(f.value)}
|
|
311
|
+
>
|
|
312
|
+
{f.label}
|
|
313
|
+
</FilterPill>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
<span className="ml-auto text-xs text-[var(--muted)]" aria-live="polite">
|
|
317
|
+
{checkedIds.size > 0 ? (
|
|
318
|
+
<>
|
|
319
|
+
{checkedIds.size} selected ·{' '}
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => setCheckedIds(new Set())}
|
|
323
|
+
className="rounded-sm text-[var(--acc)] hover:underline focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
324
|
+
>
|
|
325
|
+
Clear
|
|
326
|
+
</button>
|
|
327
|
+
</>
|
|
328
|
+
) : (
|
|
329
|
+
`${filteredTags.length} script${filteredTags.length !== 1 ? 's' : ''}`
|
|
330
|
+
)}
|
|
331
|
+
</span>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
180
334
|
</div>
|
|
181
335
|
|
|
182
336
|
<SplitPaneLayout>
|
|
@@ -188,13 +342,32 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
188
342
|
<p className="mt-1 text-sm text-[var(--sub)]">
|
|
189
343
|
Add tracking codes like Google Analytics, Tag Manager, or Facebook Pixel.
|
|
190
344
|
</p>
|
|
191
|
-
<
|
|
345
|
+
<Button
|
|
192
346
|
type="button"
|
|
347
|
+
variant="primary"
|
|
348
|
+
size="md"
|
|
349
|
+
className="mt-4"
|
|
193
350
|
onClick={() => setSelection({ mode: 'new' })}
|
|
194
|
-
|
|
351
|
+
leftIcon={<Plus size={16} aria-hidden />}
|
|
195
352
|
>
|
|
196
|
-
<Plus className="h-4 w-4" />
|
|
197
353
|
Add Your First Script
|
|
354
|
+
</Button>
|
|
355
|
+
</div>
|
|
356
|
+
) : filteredTags.length === 0 ? (
|
|
357
|
+
<div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)] p-12 text-center">
|
|
358
|
+
<Code2 className="mx-auto mb-3 h-10 w-10 text-[var(--sub)]" />
|
|
359
|
+
<h3 className="text-sm font-medium text-[var(--txt)]">
|
|
360
|
+
No scripts match your filters
|
|
361
|
+
</h3>
|
|
362
|
+
<p className="mt-1 text-sm text-[var(--sub)]">
|
|
363
|
+
Try a different search term or clear the filters.
|
|
364
|
+
</p>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onClick={clearFilters}
|
|
368
|
+
className="mt-3 rounded-sm text-sm text-[var(--acc)] hover:underline focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
369
|
+
>
|
|
370
|
+
Clear filters
|
|
198
371
|
</button>
|
|
199
372
|
</div>
|
|
200
373
|
) : (
|
|
@@ -202,6 +375,17 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
202
375
|
<table className="w-full text-sm" aria-label="Script tags">
|
|
203
376
|
<thead>
|
|
204
377
|
<tr className="bg-muted border-b border-[var(--bdr)]">
|
|
378
|
+
<th className="w-9 px-3 py-3">
|
|
379
|
+
<input
|
|
380
|
+
type="checkbox"
|
|
381
|
+
checked={allVisibleChecked}
|
|
382
|
+
onChange={toggleAllChecked}
|
|
383
|
+
aria-label={
|
|
384
|
+
allVisibleChecked ? 'Deselect all scripts' : 'Select all scripts'
|
|
385
|
+
}
|
|
386
|
+
className="h-[15px] w-[15px] cursor-pointer accent-[var(--acc)]"
|
|
387
|
+
/>
|
|
388
|
+
</th>
|
|
205
389
|
<th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Script</th>
|
|
206
390
|
<th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Location</th>
|
|
207
391
|
<th className="px-4 py-3 text-center font-medium text-[var(--sub)]">
|
|
@@ -210,20 +394,30 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
210
394
|
<th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Scope</th>
|
|
211
395
|
<th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Consent</th>
|
|
212
396
|
<th className="px-4 py-3 text-center font-medium text-[var(--sub)]">Active</th>
|
|
213
|
-
<th className="px-4 py-3 text-
|
|
397
|
+
<th className="px-4 py-3 text-right font-medium text-[var(--sub)]">Updated</th>
|
|
214
398
|
</tr>
|
|
215
399
|
</thead>
|
|
216
400
|
<tbody>
|
|
217
|
-
{
|
|
401
|
+
{filteredTags.map((tag) => {
|
|
218
402
|
const isSelected = selection?.mode === 'edit' && selection.id === tag.id
|
|
403
|
+
const consent = CONSENT_COLORS[consentCategoryOf(tag)] ?? NO_CONSENT_COLORS
|
|
219
404
|
return (
|
|
220
405
|
<tr
|
|
221
406
|
key={tag.id}
|
|
222
|
-
className={`cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-0 ${
|
|
407
|
+
className={`group cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-0 ${
|
|
223
408
|
isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
|
|
224
409
|
}`}
|
|
225
410
|
onClick={() => toggleRow(tag)}
|
|
226
411
|
>
|
|
412
|
+
<td className="px-3 py-[11px]" onClick={(e) => e.stopPropagation()}>
|
|
413
|
+
<input
|
|
414
|
+
type="checkbox"
|
|
415
|
+
checked={checkedIds.has(tag.id)}
|
|
416
|
+
onChange={() => toggleChecked(tag.id)}
|
|
417
|
+
aria-label={`Select ${tag.name}`}
|
|
418
|
+
className="h-[15px] w-[15px] cursor-pointer accent-[var(--acc)]"
|
|
419
|
+
/>
|
|
420
|
+
</td>
|
|
227
421
|
<td className="px-4 py-[11px]">
|
|
228
422
|
<button
|
|
229
423
|
type="button"
|
|
@@ -236,26 +430,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
236
430
|
>
|
|
237
431
|
{tag.name}
|
|
238
432
|
</button>
|
|
433
|
+
<div className="mt-0.5 text-[11.5px] text-[var(--muted)]">
|
|
434
|
+
Added {formatDate(tag.createdAt)}
|
|
435
|
+
</div>
|
|
239
436
|
</td>
|
|
240
437
|
<td className="px-4 py-[11px]">
|
|
241
438
|
<span
|
|
242
|
-
className={`inline-flex items-center rounded-
|
|
439
|
+
className={`inline-flex items-center rounded-[5px] px-2 py-[3px] text-[11.5px] font-medium ${PLACEMENT_COLORS[tag.placement] ?? 'bg-muted text-[var(--txt)]'}`}
|
|
243
440
|
>
|
|
244
441
|
{PLACEMENT_LABELS[tag.placement] ?? tag.placement}
|
|
245
442
|
</span>
|
|
246
443
|
</td>
|
|
247
|
-
<td className="px-4 py-[11px] text-center
|
|
248
|
-
|
|
444
|
+
<td className="px-4 py-[11px] text-center">
|
|
445
|
+
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-[var(--bdr)] bg-[var(--bg)] text-[11.5px] font-semibold text-[var(--sub)]">
|
|
446
|
+
{tag.priority}
|
|
447
|
+
</span>
|
|
249
448
|
</td>
|
|
250
449
|
<td className="px-4 py-[11px] text-[var(--sub)]">{scopeLabel(tag)}</td>
|
|
251
450
|
<td className="px-4 py-[11px]">
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
451
|
+
<span
|
|
452
|
+
className={`inline-flex items-center gap-[3px] rounded-[4px] px-[7px] py-0.5 text-[11px] font-medium ${consent.badge}`}
|
|
453
|
+
>
|
|
454
|
+
<span
|
|
455
|
+
className={`inline-block h-[5px] w-[5px] rounded-full ${consent.dot}`}
|
|
456
|
+
aria-hidden
|
|
457
|
+
/>
|
|
458
|
+
{CONSENT_LABELS[consentCategoryOf(tag)] ?? tag.consentCategory}
|
|
459
|
+
</span>
|
|
259
460
|
</td>
|
|
260
461
|
<td
|
|
261
462
|
className="px-4 py-[11px] text-center"
|
|
@@ -268,8 +469,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
268
469
|
aria-label={`${tag.enabled ? 'Disable' : 'Enable'} ${tag.name}`}
|
|
269
470
|
/>
|
|
270
471
|
</td>
|
|
271
|
-
<td className="px-4 py-[11px]
|
|
272
|
-
|
|
472
|
+
<td className="px-4 py-[11px]">
|
|
473
|
+
<div className="flex items-center justify-end gap-2">
|
|
474
|
+
<RowActions>
|
|
475
|
+
<RowActionButton
|
|
476
|
+
aria-label={`Duplicate ${tag.name}`}
|
|
477
|
+
onClick={(e) => {
|
|
478
|
+
e.stopPropagation()
|
|
479
|
+
void duplicateTag(tag)
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
<Copy size={12} aria-hidden />
|
|
483
|
+
</RowActionButton>
|
|
484
|
+
<RowActionButton
|
|
485
|
+
danger
|
|
486
|
+
aria-label={`Delete ${tag.name}`}
|
|
487
|
+
onClick={(e) => {
|
|
488
|
+
e.stopPropagation()
|
|
489
|
+
setPendingDelete(tag)
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
<Trash2 size={12} aria-hidden />
|
|
493
|
+
</RowActionButton>
|
|
494
|
+
</RowActions>
|
|
495
|
+
<span className="text-xs whitespace-nowrap text-[var(--sub)]">
|
|
496
|
+
{formatDate(tag.updatedAt)}
|
|
497
|
+
</span>
|
|
498
|
+
</div>
|
|
273
499
|
</td>
|
|
274
500
|
</tr>
|
|
275
501
|
)
|
|
@@ -289,6 +515,18 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
289
515
|
/>
|
|
290
516
|
</InspectorPane>
|
|
291
517
|
</SplitPaneLayout>
|
|
518
|
+
|
|
519
|
+
<ConfirmDialog
|
|
520
|
+
open={pendingDelete !== null}
|
|
521
|
+
title="Delete script?"
|
|
522
|
+
description={`"${pendingDelete?.name ?? 'This script'}" will stop firing on your site immediately.`}
|
|
523
|
+
confirmLabel="Delete"
|
|
524
|
+
destructive
|
|
525
|
+
onConfirm={() => {
|
|
526
|
+
if (pendingDelete) void deleteTag(pendingDelete)
|
|
527
|
+
}}
|
|
528
|
+
onClose={() => setPendingDelete(null)}
|
|
529
|
+
/>
|
|
292
530
|
</div>
|
|
293
531
|
)
|
|
294
532
|
}
|
|
@@ -21,6 +21,8 @@ import { AIGenerateDialog } from './AIGenerateDialog.js'
|
|
|
21
21
|
|
|
22
22
|
export interface PageBuilderProps {
|
|
23
23
|
documentId?: string
|
|
24
|
+
/** Seed a NEW page from a saved template (`/page-builder/new?template=<id>`). */
|
|
25
|
+
templateId?: string
|
|
24
26
|
collectionSlug: string
|
|
25
27
|
config: any
|
|
26
28
|
onNavigate: (path: string) => void
|
|
@@ -55,7 +57,13 @@ function initialSectionsCollapsed(): boolean {
|
|
|
55
57
|
return false
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
export function PageBuilder({
|
|
60
|
+
export function PageBuilder({
|
|
61
|
+
documentId,
|
|
62
|
+
templateId,
|
|
63
|
+
collectionSlug,
|
|
64
|
+
config,
|
|
65
|
+
onNavigate,
|
|
66
|
+
}: PageBuilderProps) {
|
|
59
67
|
const [loading, setLoading] = useState(!!documentId)
|
|
60
68
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
61
69
|
const [saving, setSaving] = useState(false)
|
|
@@ -100,6 +108,25 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
|
|
|
100
108
|
|
|
101
109
|
useEffect(() => {
|
|
102
110
|
if (!documentId) {
|
|
111
|
+
// A new page can be seeded from a saved template ("Use template" on
|
|
112
|
+
// the Templates grid). Load the template tree, then hand the canvas
|
|
113
|
+
// over as a dirty, unsaved draft.
|
|
114
|
+
if (templateId) {
|
|
115
|
+
let cancelledNew = false
|
|
116
|
+
setLoading(true)
|
|
117
|
+
cmsApi<{ name?: string; tree?: PageNode }>(`/page-templates/${templateId}`).then((res) => {
|
|
118
|
+
if (cancelledNew) return
|
|
119
|
+
if (res.error) {
|
|
120
|
+
toast.error(`Couldn't load template: ${res.error}`)
|
|
121
|
+
} else if (res.data?.tree) {
|
|
122
|
+
replaceTree(res.data.tree)
|
|
123
|
+
}
|
|
124
|
+
setLoading(false)
|
|
125
|
+
})
|
|
126
|
+
return () => {
|
|
127
|
+
cancelledNew = true
|
|
128
|
+
}
|
|
129
|
+
}
|
|
103
130
|
setLoading(false)
|
|
104
131
|
return
|
|
105
132
|
}
|
|
@@ -139,7 +166,8 @@ export function PageBuilder({ documentId, collectionSlug, config, onNavigate }:
|
|
|
139
166
|
return () => {
|
|
140
167
|
cancelled = true
|
|
141
168
|
}
|
|
142
|
-
|
|
169
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- replaceTree/setPageSettings are stable builder actions
|
|
170
|
+
}, [documentId, templateId, collectionSlug])
|
|
143
171
|
|
|
144
172
|
const handleSave = useCallback(async () => {
|
|
145
173
|
setSaving(true)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState } from 'react'
|
|
4
|
-
import { AlertTriangle, Layers, Pencil, RefreshCw, Trash2 } from 'lucide-react'
|
|
4
|
+
import { AlertTriangle, Copy, FilePlus, Layers, Pencil, RefreshCw, Trash2 } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { cmsApi } from '../../lib/api.js'
|
|
7
7
|
import { useApiData } from '../../lib/useApiData.js'
|
|
@@ -80,6 +80,32 @@ export function PageTemplates({ onNavigate }: PageTemplatesProps) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
async function duplicateTemplate(template: PageTemplate) {
|
|
84
|
+
// The list payload carries the full tree, so no extra fetch is needed.
|
|
85
|
+
// Duplicating a built-in is the supported way to customize one (built-ins
|
|
86
|
+
// themselves are locked against edits).
|
|
87
|
+
if (!template.tree) {
|
|
88
|
+
toast.error('Template has no layout to duplicate')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const res = await cmsApi('/page-templates', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
name: `${template.name ?? 'Untitled template'} (copy)`,
|
|
95
|
+
description: template.description ?? null,
|
|
96
|
+
category: categoryOf(template),
|
|
97
|
+
tree: template.tree,
|
|
98
|
+
thumbnail: template.thumbnail ?? null,
|
|
99
|
+
}),
|
|
100
|
+
})
|
|
101
|
+
if (res.error) {
|
|
102
|
+
toast.error(res.error)
|
|
103
|
+
} else {
|
|
104
|
+
toast.success('Template duplicated')
|
|
105
|
+
refetch()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
return (
|
|
84
110
|
<div className="p-4 pr-8">
|
|
85
111
|
{error && (
|
|
@@ -103,14 +129,15 @@ export function PageTemplates({ onNavigate }: PageTemplatesProps) {
|
|
|
103
129
|
{templates.length} saved template{templates.length === 1 ? '' : 's'}
|
|
104
130
|
</p>
|
|
105
131
|
</div>
|
|
106
|
-
<
|
|
132
|
+
<Button
|
|
107
133
|
type="button"
|
|
134
|
+
variant="outline"
|
|
135
|
+
size="md"
|
|
108
136
|
onClick={refetch}
|
|
109
|
-
|
|
137
|
+
leftIcon={<RefreshCw size={16} aria-hidden />}
|
|
110
138
|
>
|
|
111
|
-
<RefreshCw className="h-4 w-4" aria-hidden />
|
|
112
139
|
Refresh
|
|
113
|
-
</
|
|
140
|
+
</Button>
|
|
114
141
|
</div>
|
|
115
142
|
|
|
116
143
|
{!loading && templates.length > 0 && (
|
|
@@ -166,6 +193,8 @@ export function PageTemplates({ onNavigate }: PageTemplatesProps) {
|
|
|
166
193
|
<TemplateCard
|
|
167
194
|
key={template.id}
|
|
168
195
|
template={template}
|
|
196
|
+
onUse={() => onNavigate?.(`/page-builder/new?template=${template.id}`)}
|
|
197
|
+
onDuplicate={() => void duplicateTemplate(template)}
|
|
169
198
|
onEdit={() => setEditTarget(template)}
|
|
170
199
|
onDelete={() => setDeleteTarget(template)}
|
|
171
200
|
/>
|
|
@@ -197,10 +226,14 @@ export function PageTemplates({ onNavigate }: PageTemplatesProps) {
|
|
|
197
226
|
|
|
198
227
|
function TemplateCard({
|
|
199
228
|
template,
|
|
229
|
+
onUse,
|
|
230
|
+
onDuplicate,
|
|
200
231
|
onEdit,
|
|
201
232
|
onDelete,
|
|
202
233
|
}: {
|
|
203
234
|
template: PageTemplate
|
|
235
|
+
onUse: () => void
|
|
236
|
+
onDuplicate: () => void
|
|
204
237
|
onEdit: () => void
|
|
205
238
|
onDelete: () => void
|
|
206
239
|
}) {
|
|
@@ -237,16 +270,24 @@ function TemplateCard({
|
|
|
237
270
|
{sections} section{sections === 1 ? '' : 's'}
|
|
238
271
|
{updated ? ` · ${updated}` : ''}
|
|
239
272
|
</span>
|
|
240
|
-
|
|
241
|
-
<
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
273
|
+
<RowActions className="absolute top-1/2 right-0 -translate-y-1/2">
|
|
274
|
+
<RowActionButton aria-label={`Create page from ${name}`} onClick={onUse}>
|
|
275
|
+
<FilePlus size={14} aria-hidden />
|
|
276
|
+
</RowActionButton>
|
|
277
|
+
<RowActionButton aria-label={`Duplicate ${name}`} onClick={onDuplicate}>
|
|
278
|
+
<Copy size={14} aria-hidden />
|
|
279
|
+
</RowActionButton>
|
|
280
|
+
{!template.builtIn && (
|
|
281
|
+
<>
|
|
282
|
+
<RowActionButton aria-label={`Edit ${name}`} onClick={onEdit}>
|
|
283
|
+
<Pencil size={14} aria-hidden />
|
|
284
|
+
</RowActionButton>
|
|
285
|
+
<RowActionButton aria-label={`Delete ${name}`} danger onClick={onDelete}>
|
|
286
|
+
<Trash2 size={14} aria-hidden />
|
|
287
|
+
</RowActionButton>
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
290
|
+
</RowActions>
|
|
250
291
|
</div>
|
|
251
292
|
</div>
|
|
252
293
|
</li>
|