@actuate-media/cms-admin 0.70.0 → 0.71.1

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 (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/views/script-tags.render.test.d.ts +2 -0
  3. package/dist/__tests__/views/script-tags.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/views/script-tags.render.test.js +148 -0
  5. package/dist/__tests__/views/script-tags.render.test.js.map +1 -0
  6. package/dist/actuate-admin.css +1 -1
  7. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +10 -0
  8. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -1
  9. package/dist/components/script-tags/ScriptTagEditorPane.js +21 -2
  10. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  11. package/dist/components/seo/SeoIssueFixPanel.js +1 -1
  12. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  13. package/dist/components/ui/InspectorPane.d.ts.map +1 -1
  14. package/dist/components/ui/InspectorPane.js +6 -1
  15. package/dist/components/ui/InspectorPane.js.map +1 -1
  16. package/dist/views/ApiKeys.d.ts.map +1 -1
  17. package/dist/views/ApiKeys.js +1 -1
  18. package/dist/views/ApiKeys.js.map +1 -1
  19. package/dist/views/MediaBrowser.d.ts.map +1 -1
  20. package/dist/views/MediaBrowser.js +1 -1
  21. package/dist/views/MediaBrowser.js.map +1 -1
  22. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  23. package/dist/views/MediaSeoAudit.js +4 -2
  24. package/dist/views/MediaSeoAudit.js.map +1 -1
  25. package/dist/views/ScriptTags.d.ts.map +1 -1
  26. package/dist/views/ScriptTags.js +121 -15
  27. package/dist/views/ScriptTags.js.map +1 -1
  28. package/dist/views/seo/AuditTab.js +1 -1
  29. package/dist/views/seo/AuditTab.js.map +1 -1
  30. package/dist/views/seo/ContentTab.js +1 -1
  31. package/dist/views/seo/ContentTab.js.map +1 -1
  32. package/dist/views/seo/OverviewTab.js +1 -1
  33. package/dist/views/seo/OverviewTab.js.map +1 -1
  34. package/dist/views/seo/RedirectsTab.js +1 -1
  35. package/dist/views/seo/RedirectsTab.js.map +1 -1
  36. package/dist/views/seo/TechnicalTab.js +1 -1
  37. package/dist/views/seo/TechnicalTab.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/__tests__/views/script-tags.render.test.tsx +178 -0
  40. package/src/components/script-tags/ScriptTagEditorPane.tsx +40 -4
  41. package/src/components/seo/SeoIssueFixPanel.tsx +2 -2
  42. package/src/components/ui/InspectorPane.tsx +7 -2
  43. package/src/views/ApiKeys.tsx +2 -1
  44. package/src/views/MediaBrowser.tsx +3 -2
  45. package/src/views/MediaSeoAudit.tsx +3 -2
  46. package/src/views/ScriptTags.tsx +260 -25
  47. package/src/views/seo/AuditTab.tsx +1 -1
  48. package/src/views/seo/ContentTab.tsx +1 -1
  49. package/src/views/seo/OverviewTab.tsx +1 -1
  50. package/src/views/seo/RedirectsTab.tsx +1 -1
  51. package/src/views/seo/TechnicalTab.tsx +1 -1
@@ -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,40 @@ 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
- <div className="overflow-hidden rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
206
- <table className="w-full text-sm" aria-label="Script tags">
374
+ <div className="overflow-x-auto rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
375
+ {/* min-w keeps the 8 columns readable; narrow viewports scroll sideways. */}
376
+ <table className="w-full min-w-[920px] text-sm" aria-label="Script tags">
207
377
  <thead>
208
378
  <tr className="bg-muted border-b border-[var(--bdr)]">
379
+ <th className="w-9 px-3 py-3">
380
+ <input
381
+ type="checkbox"
382
+ checked={allVisibleChecked}
383
+ onChange={toggleAllChecked}
384
+ aria-label={
385
+ allVisibleChecked ? 'Deselect all scripts' : 'Select all scripts'
386
+ }
387
+ className="h-[15px] w-[15px] cursor-pointer accent-[var(--acc)]"
388
+ />
389
+ </th>
209
390
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Script</th>
210
391
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Location</th>
211
392
  <th className="px-4 py-3 text-center font-medium text-[var(--sub)]">
@@ -214,20 +395,30 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
214
395
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Scope</th>
215
396
  <th className="px-4 py-3 text-left font-medium text-[var(--sub)]">Consent</th>
216
397
  <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>
398
+ <th className="px-4 py-3 text-right font-medium text-[var(--sub)]">Updated</th>
218
399
  </tr>
219
400
  </thead>
220
401
  <tbody>
221
- {tags.map((tag) => {
402
+ {filteredTags.map((tag) => {
222
403
  const isSelected = selection?.mode === 'edit' && selection.id === tag.id
404
+ const consent = CONSENT_COLORS[consentCategoryOf(tag)] ?? NO_CONSENT_COLORS
223
405
  return (
224
406
  <tr
225
407
  key={tag.id}
226
- className={`cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-0 ${
408
+ className={`group cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-0 ${
227
409
  isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
228
410
  }`}
229
411
  onClick={() => toggleRow(tag)}
230
412
  >
413
+ <td className="px-3 py-[11px]" onClick={(e) => e.stopPropagation()}>
414
+ <input
415
+ type="checkbox"
416
+ checked={checkedIds.has(tag.id)}
417
+ onChange={() => toggleChecked(tag.id)}
418
+ aria-label={`Select ${tag.name}`}
419
+ className="h-[15px] w-[15px] cursor-pointer accent-[var(--acc)]"
420
+ />
421
+ </td>
231
422
  <td className="px-4 py-[11px]">
232
423
  <button
233
424
  type="button"
@@ -240,26 +431,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
240
431
  >
241
432
  {tag.name}
242
433
  </button>
434
+ <div className="mt-0.5 text-[11.5px] text-[var(--muted)]">
435
+ Added {formatDate(tag.createdAt)}
436
+ </div>
243
437
  </td>
244
438
  <td className="px-4 py-[11px]">
245
439
  <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)]'}`}
440
+ 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
441
  >
248
442
  {PLACEMENT_LABELS[tag.placement] ?? tag.placement}
249
443
  </span>
250
444
  </td>
251
- <td className="px-4 py-[11px] text-center font-mono text-[var(--sub)]">
252
- {tag.priority}
445
+ <td className="px-4 py-[11px] text-center">
446
+ <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)]">
447
+ {tag.priority}
448
+ </span>
253
449
  </td>
254
450
  <td className="px-4 py-[11px] text-[var(--sub)]">{scopeLabel(tag)}</td>
255
451
  <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
- )}
452
+ <span
453
+ className={`inline-flex items-center gap-[3px] rounded-[4px] px-[7px] py-0.5 text-[11px] font-medium ${consent.badge}`}
454
+ >
455
+ <span
456
+ className={`inline-block h-[5px] w-[5px] rounded-full ${consent.dot}`}
457
+ aria-hidden
458
+ />
459
+ {CONSENT_LABELS[consentCategoryOf(tag)] ?? tag.consentCategory}
460
+ </span>
263
461
  </td>
264
462
  <td
265
463
  className="px-4 py-[11px] text-center"
@@ -272,8 +470,33 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
272
470
  aria-label={`${tag.enabled ? 'Disable' : 'Enable'} ${tag.name}`}
273
471
  />
274
472
  </td>
275
- <td className="px-4 py-[11px] text-xs text-[var(--sub)]">
276
- {formatUpdated(tag.updatedAt)}
473
+ <td className="px-4 py-[11px]">
474
+ <div className="flex items-center justify-end gap-2">
475
+ <RowActions>
476
+ <RowActionButton
477
+ aria-label={`Duplicate ${tag.name}`}
478
+ onClick={(e) => {
479
+ e.stopPropagation()
480
+ void duplicateTag(tag)
481
+ }}
482
+ >
483
+ <Copy size={12} aria-hidden />
484
+ </RowActionButton>
485
+ <RowActionButton
486
+ danger
487
+ aria-label={`Delete ${tag.name}`}
488
+ onClick={(e) => {
489
+ e.stopPropagation()
490
+ setPendingDelete(tag)
491
+ }}
492
+ >
493
+ <Trash2 size={12} aria-hidden />
494
+ </RowActionButton>
495
+ </RowActions>
496
+ <span className="text-xs whitespace-nowrap text-[var(--sub)]">
497
+ {formatDate(tag.updatedAt)}
498
+ </span>
499
+ </div>
277
500
  </td>
278
501
  </tr>
279
502
  )
@@ -293,6 +516,18 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
293
516
  />
294
517
  </InspectorPane>
295
518
  </SplitPaneLayout>
519
+
520
+ <ConfirmDialog
521
+ open={pendingDelete !== null}
522
+ title="Delete script?"
523
+ description={`"${pendingDelete?.name ?? 'This script'}" will stop firing on your site immediately.`}
524
+ confirmLabel="Delete"
525
+ destructive
526
+ onConfirm={() => {
527
+ if (pendingDelete) void deleteTag(pendingDelete)
528
+ }}
529
+ onClose={() => setPendingDelete(null)}
530
+ />
296
531
  </div>
297
532
  )
298
533
  }
@@ -282,7 +282,7 @@ export function AuditTab({
282
282
  />
283
283
  ) : (
284
284
  <div className="overflow-x-auto">
285
- <table className="w-full text-sm" aria-label="SEO issues">
285
+ <table className="w-full min-w-[680px] text-sm" aria-label="SEO issues">
286
286
  <thead>
287
287
  <tr className="text-muted-foreground border-border border-b text-left">
288
288
  <th className="py-2 pr-4 font-medium">Severity</th>
@@ -116,7 +116,7 @@ export function ContentTab({
116
116
  />
117
117
  ) : (
118
118
  <div className="overflow-x-auto">
119
- <table className="w-full text-sm" aria-label="Content SEO">
119
+ <table className="w-full min-w-[640px] text-sm" aria-label="Content SEO">
120
120
  <thead>
121
121
  <tr className="border-b border-[var(--bdr)] text-left text-[var(--sub)]">
122
122
  <th className="py-2 pr-4 font-medium">Title</th>
@@ -216,7 +216,7 @@ export function OverviewTab({
216
216
  <SeoEmptyState title="No search performance data" />
217
217
  ) : (
218
218
  <div className="overflow-x-auto">
219
- <table className="w-full text-sm" aria-label="Search performance">
219
+ <table className="w-full min-w-[640px] text-sm" aria-label="Search performance">
220
220
  <thead>
221
221
  <tr className="text-muted-foreground border-border border-b text-left">
222
222
  <th className="py-2 pr-4 font-medium">Page</th>
@@ -327,7 +327,7 @@ export function RedirectsTab() {
327
327
  />
328
328
  ) : (
329
329
  <div className="overflow-x-auto">
330
- <table className="w-full text-sm" aria-label="Redirect rules">
330
+ <table className="w-full min-w-[760px] text-sm" aria-label="Redirect rules">
331
331
  <thead>
332
332
  <tr className="text-muted-foreground border-border border-b text-left">
333
333
  <th className="py-2 pr-4 font-medium">Source</th>
@@ -362,7 +362,7 @@ function AiCrawlerCard() {
362
362
  </div>
363
363
  </dl>
364
364
  <div className="overflow-x-auto">
365
- <table className="w-full text-sm" aria-label="AI crawler activity by bot">
365
+ <table className="w-full min-w-[640px] text-sm" aria-label="AI crawler activity by bot">
366
366
  <thead>
367
367
  <tr className="border-border text-muted-foreground border-b text-left">
368
368
  <th scope="col" className="py-2 pr-3 font-medium">