@actuate-media/cms-admin 0.70.0 → 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.
@@ -1,14 +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
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'
8
12
  import { StatPill } from '../components/ui/StatPill.js'
9
13
  import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
10
14
  import {
15
+ CONSENT_COLORS,
11
16
  CONSENT_LABELS,
17
+ NO_CONSENT_COLORS,
18
+ PLACEMENT_COLORS,
12
19
  PLACEMENT_LABELS,
13
20
  ScriptTagEditorPane,
14
21
  type ScriptTag,
@@ -24,11 +31,21 @@ export interface ScriptTagsProps {
24
31
  initialNew?: boolean
25
32
  }
26
33
 
27
- const PLACEMENT_COLORS: Record<string, string> = {
28
- head: 'bg-accent text-accent-foreground',
29
- body_open: 'bg-primary/15 text-primary',
30
- body_close: 'bg-success/15 text-success',
31
- }
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
+ ]
32
49
 
33
50
  function normalizeEnabled(value: unknown): boolean {
34
51
  if (typeof value === 'boolean') return value
@@ -52,18 +69,27 @@ function scopeLabel(tag: ScriptTag): string {
52
69
  return scope || 'Custom'
53
70
  }
54
71
 
55
- function formatUpdated(value: string): string {
72
+ function formatDate(value: string): string {
56
73
  const date = new Date(value)
57
74
  if (Number.isNaN(date.getTime())) return '—'
58
75
  return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
59
76
  }
60
77
 
78
+ function consentCategoryOf(tag: ScriptTag): string {
79
+ return tag.consentCategory && tag.consentCategory !== '' ? tag.consentCategory : 'none'
80
+ }
81
+
61
82
  type Selection = { mode: 'edit'; id: string } | { mode: 'new' } | null
62
83
 
63
84
  export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
64
85
  const { data, loading, error, refetch } = useApiData<ScriptTag[]>('/script-tags')
65
86
  const [togglingId, setTogglingId] = useState<string | null>(null)
66
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)
67
93
  const [selection, setSelection] = useState<Selection>(
68
94
  initialNew
69
95
  ? { mode: 'new' }
@@ -81,6 +107,20 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
81
107
  [data, enabledOverrides],
82
108
  )
83
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
+
84
124
  const selectedTag =
85
125
  selection?.mode === 'edit' ? (tags.find((t) => t.id === selection.id) ?? null) : null
86
126
  const paneOpen = selection?.mode === 'new' || selectedTag !== null
@@ -91,6 +131,25 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
91
131
  )
92
132
  }
93
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
+
94
153
  const toggleEnabled = async (tag: ScriptTag, nextEnabled: boolean) => {
95
154
  setEnabledOverrides((prev) => ({ ...prev, [tag.id]: nextEnabled }))
96
155
  setTogglingId(tag.id)
@@ -117,6 +176,48 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
117
176
  toast.success(saved ? 'Tag enabled' : 'Tag disabled')
118
177
  }
119
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
+
120
221
  const handlePaneChanged = (savedId?: string) => {
121
222
  setEnabledOverrides({})
122
223
  refetch()
@@ -151,7 +252,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
151
252
  <div>
152
253
  <h1 className="mb-1 text-[var(--txt)]">Script Tags</h1>
153
254
  <p className="text-sm text-[var(--sub)]">
154
- Manage tracking codes, analytics, and custom scripts injected into your site
255
+ Inject scripts into the frontend never into CMS rendering
155
256
  </p>
156
257
  {tags.length > 0 && (
157
258
  <div className="mt-3 flex flex-wrap gap-1.5">
@@ -166,6 +267,11 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
166
267
  label="Inactive"
167
268
  valueClassName="text-[var(--muted)]"
168
269
  />
270
+ <StatPill
271
+ value={consentGatedCount}
272
+ label="Consent-gated"
273
+ icon={<Shield size={12} className="text-[var(--acc)]" aria-hidden />}
274
+ />
169
275
  </div>
170
276
  )}
171
277
  </div>
@@ -179,6 +285,52 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
179
285
  New Script
180
286
  </Button>
181
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
+ )}
182
334
  </div>
183
335
 
184
336
  <SplitPaneLayout>
@@ -201,11 +353,39 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
201
353
  Add Your First Script
202
354
  </Button>
203
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
371
+ </button>
372
+ </div>
204
373
  ) : (
205
374
  <div className="overflow-hidden rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
206
375
  <table className="w-full text-sm" aria-label="Script tags">
207
376
  <thead>
208
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>
209
389
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Script</th>
210
390
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Location</th>
211
391
  <th className="px-4 py-3 text-center font-medium text-[var(--sub)]">
@@ -214,20 +394,30 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
214
394
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Scope</th>
215
395
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Consent</th>
216
396
  <th className="px-4 py-3 text-center font-medium text-[var(--sub)]">Active</th>
217
- <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>
218
398
  </tr>
219
399
  </thead>
220
400
  <tbody>
221
- {tags.map((tag) => {
401
+ {filteredTags.map((tag) => {
222
402
  const isSelected = selection?.mode === 'edit' && selection.id === tag.id
403
+ const consent = CONSENT_COLORS[consentCategoryOf(tag)] ?? NO_CONSENT_COLORS
223
404
  return (
224
405
  <tr
225
406
  key={tag.id}
226
- 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 ${
227
408
  isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
228
409
  }`}
229
410
  onClick={() => toggleRow(tag)}
230
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>
231
421
  <td className="px-4 py-[11px]">
232
422
  <button
233
423
  type="button"
@@ -240,26 +430,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
240
430
  >
241
431
  {tag.name}
242
432
  </button>
433
+ <div className="mt-0.5 text-[11.5px] text-[var(--muted)]">
434
+ Added {formatDate(tag.createdAt)}
435
+ </div>
243
436
  </td>
244
437
  <td className="px-4 py-[11px]">
245
438
  <span
246
- 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)]'}`}
247
440
  >
248
441
  {PLACEMENT_LABELS[tag.placement] ?? tag.placement}
249
442
  </span>
250
443
  </td>
251
- <td className="px-4 py-[11px] text-center font-mono text-[var(--sub)]">
252
- {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>
253
448
  </td>
254
449
  <td className="px-4 py-[11px] text-[var(--sub)]">{scopeLabel(tag)}</td>
255
450
  <td className="px-4 py-[11px]">
256
- {tag.consentCategory && tag.consentCategory !== 'none' ? (
257
- <span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-1.5 py-0.5 text-[11px] text-[var(--sub)]">
258
- {CONSENT_LABELS[tag.consentCategory] ?? tag.consentCategory}
259
- </span>
260
- ) : (
261
- <span className="text-xs text-[var(--sub)]">—</span>
262
- )}
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>
263
460
  </td>
264
461
  <td
265
462
  className="px-4 py-[11px] text-center"
@@ -272,8 +469,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
272
469
  aria-label={`${tag.enabled ? 'Disable' : 'Enable'} ${tag.name}`}
273
470
  />
274
471
  </td>
275
- <td className="px-4 py-[11px] text-xs text-[var(--sub)]">
276
- {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>
277
499
  </td>
278
500
  </tr>
279
501
  )
@@ -293,6 +515,18 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
293
515
  />
294
516
  </InspectorPane>
295
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
+ />
296
530
  </div>
297
531
  )
298
532
  }