@actuate-media/cms-admin 0.69.1 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-tree.test.js +40 -1
  6. package/dist/__tests__/lib/pages-tree.test.js.map +1 -1
  7. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/page-builder-template-seed.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/page-builder-template-seed.render.test.js +48 -0
  10. package/dist/__tests__/views/page-builder-template-seed.render.test.js.map +1 -0
  11. package/dist/__tests__/views/page-templates.render.test.js +21 -0
  12. package/dist/__tests__/views/page-templates.render.test.js.map +1 -1
  13. package/dist/__tests__/views/script-tags.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/script-tags.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/script-tags.render.test.js +148 -0
  16. package/dist/__tests__/views/script-tags.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/TipTapEditor.d.ts.map +1 -1
  19. package/dist/components/TipTapEditor.js +11 -9
  20. package/dist/components/TipTapEditor.js.map +1 -1
  21. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +10 -0
  22. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -1
  23. package/dist/components/script-tags/ScriptTagEditorPane.js +21 -2
  24. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/pages-service.d.ts +7 -0
  30. package/dist/lib/pages-service.d.ts.map +1 -1
  31. package/dist/lib/pages-service.js +11 -0
  32. package/dist/lib/pages-service.js.map +1 -1
  33. package/dist/lib/pages-tree.d.ts +14 -0
  34. package/dist/lib/pages-tree.d.ts.map +1 -1
  35. package/dist/lib/pages-tree.js +51 -0
  36. package/dist/lib/pages-tree.js.map +1 -1
  37. package/dist/views/FormEditor.js +5 -7
  38. package/dist/views/FormEditor.js.map +1 -1
  39. package/dist/views/HiddenLoginNotFound.d.ts +6 -0
  40. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -1
  41. package/dist/views/HiddenLoginNotFound.js +6 -0
  42. package/dist/views/HiddenLoginNotFound.js.map +1 -1
  43. package/dist/views/MediaBrowser.d.ts.map +1 -1
  44. package/dist/views/MediaBrowser.js +2 -1
  45. package/dist/views/MediaBrowser.js.map +1 -1
  46. package/dist/views/PageEditor.d.ts +4 -0
  47. package/dist/views/PageEditor.d.ts.map +1 -1
  48. package/dist/views/PageEditor.js +4 -0
  49. package/dist/views/PageEditor.js.map +1 -1
  50. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  51. package/dist/views/Pages/PagesListView.js +40 -5
  52. package/dist/views/Pages/PagesListView.js.map +1 -1
  53. package/dist/views/Pages/PagesTreeTable.d.ts +11 -1
  54. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  55. package/dist/views/Pages/PagesTreeTable.js +40 -9
  56. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  57. package/dist/views/Pages.d.ts +4 -0
  58. package/dist/views/Pages.d.ts.map +1 -1
  59. package/dist/views/Pages.js +4 -0
  60. package/dist/views/Pages.js.map +1 -1
  61. package/dist/views/Redirects.d.ts +4 -0
  62. package/dist/views/Redirects.d.ts.map +1 -1
  63. package/dist/views/Redirects.js +4 -0
  64. package/dist/views/Redirects.js.map +1 -1
  65. package/dist/views/ScriptTags.d.ts.map +1 -1
  66. package/dist/views/ScriptTags.js +122 -15
  67. package/dist/views/ScriptTags.js.map +1 -1
  68. package/dist/views/page-builder/PageBuilder.d.ts +3 -1
  69. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  70. package/dist/views/page-builder/PageBuilder.js +24 -2
  71. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  72. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  73. package/dist/views/page-builder/PageTemplates.js +30 -4
  74. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/AdminRoot.tsx +11 -1
  77. package/src/__tests__/lib/pages-tree.test.ts +49 -0
  78. package/src/__tests__/views/page-builder-template-seed.render.test.tsx +59 -0
  79. package/src/__tests__/views/page-templates.render.test.tsx +40 -0
  80. package/src/__tests__/views/script-tags.render.test.tsx +178 -0
  81. package/src/components/TipTapEditor.tsx +11 -9
  82. package/src/components/script-tags/ScriptTagEditorPane.tsx +40 -4
  83. package/src/index.ts +3 -0
  84. package/src/lib/pages-service.ts +15 -0
  85. package/src/lib/pages-tree.ts +54 -0
  86. package/src/views/FormEditor.tsx +10 -10
  87. package/src/views/HiddenLoginNotFound.tsx +6 -0
  88. package/src/views/MediaBrowser.tsx +8 -9
  89. package/src/views/PageEditor.tsx +4 -0
  90. package/src/views/Pages/PagesListView.tsx +54 -9
  91. package/src/views/Pages/PagesTreeTable.tsx +203 -111
  92. package/src/views/Pages.tsx +4 -0
  93. package/src/views/Redirects.tsx +4 -0
  94. package/src/views/ScriptTags.tsx +268 -30
  95. package/src/views/page-builder/PageBuilder.tsx +30 -2
  96. package/src/views/page-builder/PageTemplates.tsx +56 -15
@@ -1,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 PLACEMENT_COLORS: Record<string, string> = {
27
- head: 'bg-accent text-accent-foreground',
28
- body_open: 'bg-primary/15 text-primary',
29
- body_close: 'bg-success/15 text-success',
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 formatUpdated(value: string): string {
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
- Manage tracking codes, analytics, and custom scripts injected into your site
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
- <button
278
+ <Button
172
279
  type="button"
280
+ variant="primary"
281
+ size="md"
173
282
  onClick={() => setSelection({ mode: 'new' })}
174
- className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
283
+ leftIcon={<Plus size={16} aria-hidden />}
175
284
  >
176
- <Plus className="h-4 w-4" />
177
285
  New Script
178
- </button>
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
- <button
345
+ <Button
192
346
  type="button"
347
+ variant="primary"
348
+ size="md"
349
+ className="mt-4"
193
350
  onClick={() => setSelection({ mode: 'new' })}
194
- className="bg-primary text-primary-foreground hover:bg-primary/90 mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
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-left font-medium text-[var(--sub)]">Updated</th>
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
- {tags.map((tag) => {
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-full px-2.5 py-0.5 text-xs font-medium ${PLACEMENT_COLORS[tag.placement] ?? 'bg-muted text-[var(--txt)]'}`}
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 font-mono text-[var(--sub)]">
248
- {tag.priority}
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
- {tag.consentCategory && tag.consentCategory !== 'none' ? (
253
- <span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-1.5 py-0.5 text-[11px] text-[var(--sub)]">
254
- {CONSENT_LABELS[tag.consentCategory] ?? tag.consentCategory}
255
- </span>
256
- ) : (
257
- <span className="text-xs text-[var(--sub)]">—</span>
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] text-xs text-[var(--sub)]">
272
- {formatUpdated(tag.updatedAt)}
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({ documentId, collectionSlug, config, onNavigate }: PageBuilderProps) {
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
- }, [documentId, collectionSlug])
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
- <button
132
+ <Button
107
133
  type="button"
134
+ variant="outline"
135
+ size="md"
108
136
  onClick={refetch}
109
- className="inline-flex items-center gap-2 rounded-md border border-[var(--bdr)] px-3 py-2 text-sm font-medium text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc-m)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)]"
137
+ leftIcon={<RefreshCw size={16} aria-hidden />}
110
138
  >
111
- <RefreshCw className="h-4 w-4" aria-hidden />
112
139
  Refresh
113
- </button>
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
- {!template.builtIn && (
241
- <RowActions className="absolute top-1/2 right-0 -translate-y-1/2">
242
- <RowActionButton aria-label={`Edit ${name}`} onClick={onEdit}>
243
- <Pencil size={14} aria-hidden />
244
- </RowActionButton>
245
- <RowActionButton aria-label={`Delete ${name}`} danger onClick={onDelete}>
246
- <Trash2 size={14} aria-hidden />
247
- </RowActionButton>
248
- </RowActions>
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>