@actuate-media/cms-admin 0.71.0 → 0.72.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 (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/author-avatar.test.d.ts +2 -0
  3. package/dist/__tests__/components/author-avatar.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/author-avatar.test.js +36 -0
  5. package/dist/__tests__/components/author-avatar.test.js.map +1 -0
  6. package/dist/__tests__/views/pages-list-view.test.js +60 -0
  7. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/seo/SeoIssueFixPanel.js +1 -1
  10. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  11. package/dist/components/ui/AuthorAvatar.d.ts +11 -0
  12. package/dist/components/ui/AuthorAvatar.d.ts.map +1 -0
  13. package/dist/components/ui/AuthorAvatar.js +47 -0
  14. package/dist/components/ui/AuthorAvatar.js.map +1 -0
  15. package/dist/components/ui/InspectorPane.d.ts.map +1 -1
  16. package/dist/components/ui/InspectorPane.js +6 -1
  17. package/dist/components/ui/InspectorPane.js.map +1 -1
  18. package/dist/views/ApiKeys.d.ts.map +1 -1
  19. package/dist/views/ApiKeys.js +1 -1
  20. package/dist/views/ApiKeys.js.map +1 -1
  21. package/dist/views/Dashboard.d.ts.map +1 -1
  22. package/dist/views/Dashboard.js +3 -29
  23. package/dist/views/Dashboard.js.map +1 -1
  24. package/dist/views/MediaBrowser.d.ts.map +1 -1
  25. package/dist/views/MediaBrowser.js +1 -1
  26. package/dist/views/MediaBrowser.js.map +1 -1
  27. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  28. package/dist/views/MediaSeoAudit.js +4 -2
  29. package/dist/views/MediaSeoAudit.js.map +1 -1
  30. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  31. package/dist/views/Pages/PagesListView.js +82 -12
  32. package/dist/views/Pages/PagesListView.js.map +1 -1
  33. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  34. package/dist/views/Pages/PagesTreeTable.js +2 -1
  35. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  36. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  37. package/dist/views/Posts/PostsListView.js +2 -1
  38. package/dist/views/Posts/PostsListView.js.map +1 -1
  39. package/dist/views/ScriptTags.d.ts.map +1 -1
  40. package/dist/views/ScriptTags.js +1 -1
  41. package/dist/views/ScriptTags.js.map +1 -1
  42. package/dist/views/seo/AuditTab.js +1 -1
  43. package/dist/views/seo/AuditTab.js.map +1 -1
  44. package/dist/views/seo/ContentTab.js +1 -1
  45. package/dist/views/seo/ContentTab.js.map +1 -1
  46. package/dist/views/seo/OverviewTab.js +1 -1
  47. package/dist/views/seo/OverviewTab.js.map +1 -1
  48. package/dist/views/seo/RedirectsTab.js +1 -1
  49. package/dist/views/seo/RedirectsTab.js.map +1 -1
  50. package/dist/views/seo/TechnicalTab.js +1 -1
  51. package/dist/views/seo/TechnicalTab.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/__tests__/components/author-avatar.test.tsx +40 -0
  54. package/src/__tests__/views/pages-list-view.test.tsx +81 -0
  55. package/src/components/seo/SeoIssueFixPanel.tsx +2 -2
  56. package/src/components/ui/AuthorAvatar.tsx +69 -0
  57. package/src/components/ui/InspectorPane.tsx +7 -2
  58. package/src/views/ApiKeys.tsx +2 -1
  59. package/src/views/Dashboard.tsx +3 -32
  60. package/src/views/MediaBrowser.tsx +3 -2
  61. package/src/views/MediaSeoAudit.tsx +3 -2
  62. package/src/views/Pages/PagesListView.tsx +216 -63
  63. package/src/views/Pages/PagesTreeTable.tsx +2 -6
  64. package/src/views/Posts/PostsListView.tsx +2 -6
  65. package/src/views/ScriptTags.tsx +3 -2
  66. package/src/views/seo/AuditTab.tsx +1 -1
  67. package/src/views/seo/ContentTab.tsx +1 -1
  68. package/src/views/seo/OverviewTab.tsx +1 -1
  69. package/src/views/seo/RedirectsTab.tsx +1 -1
  70. package/src/views/seo/TechnicalTab.tsx +1 -1
@@ -2,10 +2,12 @@
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import {
5
+ Check,
5
6
  ChevronDown,
6
7
  ChevronLeft,
7
8
  ChevronRight,
8
9
  ChevronUp,
10
+ Columns3,
9
11
  Copy,
10
12
  List,
11
13
  ListTree,
@@ -44,6 +46,7 @@ import {
44
46
  } from '../../lib/pages-tree.js'
45
47
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
46
48
  import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
49
+ import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
47
50
  import { Button } from '../../components/ui/Button.js'
48
51
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
49
52
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
@@ -69,8 +72,17 @@ type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
69
72
  type Dir = 'asc' | 'desc'
70
73
  type ViewMode = 'list' | 'tree'
71
74
 
75
+ /** Optional flat-list columns the user can hide (design's "Tweaks" panel). */
76
+ type ToggleableColumn = 'tags' | 'author' | 'seo'
77
+ const TOGGLEABLE_COLUMNS: Array<{ key: ToggleableColumn; label: string }> = [
78
+ { key: 'tags', label: 'Tags' },
79
+ { key: 'author', label: 'Author' },
80
+ { key: 'seo', label: 'SEO score' },
81
+ ]
82
+
72
83
  const PAGE_SIZE = 25
73
84
  const VIEW_MODE_KEY = 'actuate.pages.viewMode'
85
+ const HIDDEN_COLUMNS_KEY = 'actuate.pages.hiddenColumns'
74
86
 
75
87
  function readStoredViewMode(): ViewMode {
76
88
  if (typeof window === 'undefined') return 'list'
@@ -81,6 +93,20 @@ function readStoredViewMode(): ViewMode {
81
93
  }
82
94
  }
83
95
 
96
+ function readStoredHiddenColumns(): Set<ToggleableColumn> {
97
+ if (typeof window === 'undefined') return new Set()
98
+ try {
99
+ const raw = localStorage.getItem(HIDDEN_COLUMNS_KEY)
100
+ if (!raw) return new Set()
101
+ const known = new Set(TOGGLEABLE_COLUMNS.map((c) => c.key))
102
+ const parsed: unknown = JSON.parse(raw)
103
+ if (!Array.isArray(parsed)) return new Set()
104
+ return new Set(parsed.filter((v): v is ToggleableColumn => known.has(v as ToggleableColumn)))
105
+ } catch {
106
+ return new Set()
107
+ }
108
+ }
109
+
84
110
  export function PagesListView({ onNavigate }: PagesListViewProps) {
85
111
  const [search, setSearch] = useState('')
86
112
  const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -92,6 +118,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
92
118
  const [page, setPage] = useState(1)
93
119
  const [selected, setSelected] = useState<Set<string>>(new Set())
94
120
  const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode)
121
+ const [hiddenCols, setHiddenCols] = useState<Set<ToggleableColumn>>(readStoredHiddenColumns)
95
122
  // null = "not yet touched" → falls back to the default expand state
96
123
  // (all root nodes with children open) computed from the current tree.
97
124
  const [expandedOverride, setExpandedOverride] = useState<Set<string> | null>(null)
@@ -223,6 +250,38 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
223
250
  }
224
251
  }
225
252
 
253
+ const toggleColumn = (key: ToggleableColumn) => {
254
+ setHiddenCols((prev) => {
255
+ const next = new Set(prev)
256
+ if (next.has(key)) next.delete(key)
257
+ else next.add(key)
258
+ try {
259
+ localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...next]))
260
+ } catch {
261
+ /* storage unavailable — the choice still applies for the session */
262
+ }
263
+ return next
264
+ })
265
+ // Hiding the column you're sorted by would leave an invisible sort —
266
+ // fall back to the default title sort instead.
267
+ if ((key === 'author' && sortBy === 'author') || (key === 'seo' && sortBy === 'seo')) {
268
+ setSortBy('title')
269
+ setDir('asc')
270
+ }
271
+ }
272
+
273
+ const showTags = !hiddenCols.has('tags')
274
+ const showAuthor = !hiddenCols.has('author')
275
+ const showSeo = !hiddenCols.has('seo')
276
+ // checkbox + title + status + modified + actions are always visible.
277
+ const columnCount = 5 + (showTags ? 1 : 0) + (showAuthor ? 1 : 0) + (showSeo ? 1 : 0)
278
+ // The table is `table-fixed`, so the flexible Title column only gets
279
+ // whatever the min-width leaves after the fixed columns (400px always
280
+ // visible + optional ones). Keep ≥240px for the title or it collapses
281
+ // and headers overlap on narrow viewports.
282
+ const tableMinWidth =
283
+ 400 + 240 + (showTags ? 200 : 0) + (showAuthor ? 170 : 0) + (showSeo ? 80 : 0)
284
+
226
285
  const handleSort = useCallback(
227
286
  (field: SortBy) => {
228
287
  if (sortBy === field) {
@@ -520,6 +579,8 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
520
579
 
521
580
  <ViewModeToggle mode={viewMode} onChange={changeViewMode} />
522
581
 
582
+ {!treeMode && <ColumnsMenu hidden={hiddenCols} onToggle={toggleColumn} />}
583
+
523
584
  <span className="ml-auto text-sm text-[var(--sub)] tabular-nums">
524
585
  {treeMode
525
586
  ? treeFiltersActive
@@ -611,15 +672,20 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
611
672
  {/* Horizontal scroll on narrow viewports: the fixed columns keep their
612
673
  width and the table scrolls sideways instead of crushing content. */}
613
674
  <div className="overflow-x-auto">
614
- <table className="w-full min-w-[800px] table-fixed" role="table" aria-label="Pages">
675
+ <table
676
+ className="w-full table-fixed"
677
+ style={{ minWidth: tableMinWidth }}
678
+ role="table"
679
+ aria-label="Pages"
680
+ >
615
681
  <colgroup>
616
682
  <col style={{ width: '40px' }} />
617
683
  <col />
618
- <col style={{ width: '200px' }} />
619
- <col style={{ width: '170px' }} />
684
+ {showTags && <col style={{ width: '200px' }} />}
685
+ {showAuthor && <col style={{ width: '170px' }} />}
620
686
  <col style={{ width: '120px' }} />
621
687
  <col style={{ width: '130px' }} />
622
- <col style={{ width: '80px' }} />
688
+ {showSeo && <col style={{ width: '80px' }} />}
623
689
  <col style={{ width: '110px' }} />
624
690
  </colgroup>
625
691
  <thead className="bg-muted/40 border-b border-[var(--bdr)]">
@@ -646,22 +712,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
646
712
  onSort={handleSort}
647
713
  />
648
714
  </th>
649
- <th scope="col" className="px-3 py-2.5 text-left">
650
- <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
651
- </th>
652
- <th
653
- scope="col"
654
- aria-sort={ariaSortFor('author')}
655
- className="px-3 py-2.5 text-left"
656
- >
657
- <SortHeader
658
- label="Author"
659
- field="author"
660
- current={sortBy}
661
- dir={dir}
662
- onSort={handleSort}
663
- />
664
- </th>
715
+ {showTags && (
716
+ <th scope="col" className="px-3 py-2.5 text-left">
717
+ <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
718
+ </th>
719
+ )}
720
+ {showAuthor && (
721
+ <th
722
+ scope="col"
723
+ aria-sort={ariaSortFor('author')}
724
+ className="px-3 py-2.5 text-left"
725
+ >
726
+ <SortHeader
727
+ label="Author"
728
+ field="author"
729
+ current={sortBy}
730
+ dir={dir}
731
+ onSort={handleSort}
732
+ />
733
+ </th>
734
+ )}
665
735
  <th
666
736
  scope="col"
667
737
  aria-sort={ariaSortFor('status')}
@@ -688,29 +758,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
688
758
  onSort={handleSort}
689
759
  />
690
760
  </th>
691
- <th
692
- scope="col"
693
- aria-sort={ariaSortFor('seo')}
694
- className="px-3 py-2.5 text-left"
695
- >
696
- <SortHeader
697
- label="SEO"
698
- field="seo"
699
- current={sortBy}
700
- dir={dir}
701
- onSort={handleSort}
702
- />
703
- </th>
761
+ {showSeo && (
762
+ <th
763
+ scope="col"
764
+ aria-sort={ariaSortFor('seo')}
765
+ className="px-3 py-2.5 text-left"
766
+ >
767
+ <SortHeader
768
+ label="SEO"
769
+ field="seo"
770
+ current={sortBy}
771
+ dir={dir}
772
+ onSort={handleSort}
773
+ />
774
+ </th>
775
+ )}
704
776
  <th scope="col" className="px-3 py-2.5 text-right">
705
777
  <span className="sr-only">Actions</span>
706
778
  </th>
707
779
  </tr>
708
780
  </thead>
709
781
  <tbody>
710
- {loading && pages.length === 0 && <LoadingRows />}
782
+ {loading && pages.length === 0 && <LoadingRows columns={columnCount} />}
711
783
  {!loading && error && (
712
784
  <tr>
713
- <td colSpan={8} className="px-3 py-12 text-center">
785
+ <td colSpan={columnCount} className="px-3 py-12 text-center">
714
786
  <div className="mb-2 text-sm font-medium text-[var(--txt)]">{error}</div>
715
787
  <button
716
788
  onClick={() => setRefreshToken((t) => t + 1)}
@@ -723,6 +795,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
723
795
  )}
724
796
  {!loading && !error && pages.length === 0 && (
725
797
  <EmptyRow
798
+ columns={columnCount}
726
799
  filtersActive={filtersAreActive}
727
800
  onClear={clearFilters}
728
801
  onNewPage={() => onNavigate('/pages/new')}
@@ -737,12 +810,16 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
737
810
  label={group.label}
738
811
  color={group.color}
739
812
  count={group.pages.length}
813
+ columns={columnCount}
740
814
  >
741
815
  {group.pages.map((p) => (
742
816
  <PageRow
743
817
  key={p.id}
744
818
  page={p}
745
819
  selected={selected.has(p.id)}
820
+ showTags={showTags}
821
+ showAuthor={showAuthor}
822
+ showSeo={showSeo}
746
823
  resolveTag={(slug) => tagBySlug.get(slug)}
747
824
  onSelect={() => toggleOne(p.id)}
748
825
  onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
@@ -880,6 +957,70 @@ function ViewModeToggle({
880
957
  )
881
958
  }
882
959
 
960
+ /**
961
+ * Column visibility menu — the product translation of the design's
962
+ * floating "Tweaks" panel. Optional columns can be hidden per user;
963
+ * the choice persists in localStorage alongside the view mode.
964
+ */
965
+ function ColumnsMenu({
966
+ hidden,
967
+ onToggle,
968
+ }: {
969
+ hidden: Set<ToggleableColumn>
970
+ onToggle: (key: ToggleableColumn) => void
971
+ }) {
972
+ return (
973
+ <DropdownMenu.Root>
974
+ <DropdownMenu.Trigger asChild>
975
+ <button
976
+ type="button"
977
+ aria-label="Choose visible columns"
978
+ className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-sm text-[var(--txt)] shadow-sm transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
979
+ >
980
+ <Columns3 size={15} aria-hidden />
981
+ Columns
982
+ <ChevronDown className="h-3 w-3 text-[var(--sub)]" aria-hidden />
983
+ </button>
984
+ </DropdownMenu.Trigger>
985
+ <DropdownMenu.Portal container={adminPortalContainer()}>
986
+ <DropdownMenu.Content
987
+ align="end"
988
+ sideOffset={4}
989
+ className="bg-popover z-50 w-44 rounded-lg border border-[var(--bdr)] p-1 shadow-md"
990
+ >
991
+ <DropdownMenu.Label className="px-2 py-1.5 text-[10px] font-medium tracking-wider text-[var(--sub)] uppercase">
992
+ Columns
993
+ </DropdownMenu.Label>
994
+ {TOGGLEABLE_COLUMNS.map((col) => {
995
+ const visible = !hidden.has(col.key)
996
+ return (
997
+ <DropdownMenu.CheckboxItem
998
+ key={col.key}
999
+ checked={visible}
1000
+ onCheckedChange={() => onToggle(col.key)}
1001
+ onSelect={(e) => e.preventDefault()}
1002
+ className="focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-[var(--txt)] outline-none"
1003
+ >
1004
+ <span
1005
+ aria-hidden
1006
+ className={`flex h-4 w-4 items-center justify-center rounded border ${
1007
+ visible
1008
+ ? 'bg-primary border-transparent text-white'
1009
+ : 'border-[var(--bdr)] bg-[var(--card)]'
1010
+ }`}
1011
+ >
1012
+ {visible && <Check size={11} strokeWidth={3} />}
1013
+ </span>
1014
+ {col.label}
1015
+ </DropdownMenu.CheckboxItem>
1016
+ )
1017
+ })}
1018
+ </DropdownMenu.Content>
1019
+ </DropdownMenu.Portal>
1020
+ </DropdownMenu.Root>
1021
+ )
1022
+ }
1023
+
883
1024
  function FilterSelect({
884
1025
  label,
885
1026
  value,
@@ -914,19 +1055,21 @@ function GroupBlock({
914
1055
  label,
915
1056
  color,
916
1057
  count,
1058
+ columns,
917
1059
  children,
918
1060
  }: {
919
1061
  showHeader: boolean
920
1062
  label: string
921
1063
  color: string | null
922
1064
  count: number
1065
+ columns: number
923
1066
  children: React.ReactNode
924
1067
  }) {
925
1068
  return (
926
1069
  <>
927
1070
  {showHeader && (
928
1071
  <tr className="bg-muted/30 border-b border-[var(--bdr)]">
929
- <td colSpan={8} className="px-3 py-2">
1072
+ <td colSpan={columns} className="px-3 py-2">
930
1073
  <span className="inline-flex items-center gap-2 text-xs font-medium tracking-wider text-[var(--txt)] uppercase">
931
1074
  {color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
932
1075
  {label}
@@ -1066,6 +1209,9 @@ function TagMenu({
1066
1209
  function PageRow({
1067
1210
  page,
1068
1211
  selected,
1212
+ showTags,
1213
+ showAuthor,
1214
+ showSeo,
1069
1215
  resolveTag,
1070
1216
  onSelect,
1071
1217
  onOpen,
@@ -1074,6 +1220,9 @@ function PageRow({
1074
1220
  }: {
1075
1221
  page: Page
1076
1222
  selected: boolean
1223
+ showTags: boolean
1224
+ showAuthor: boolean
1225
+ showSeo: boolean
1077
1226
  resolveTag: (slug: string) => { name: string; color: string | null } | undefined
1078
1227
  onSelect: () => void
1079
1228
  onOpen: () => void
@@ -1096,39 +1245,41 @@ function PageRow({
1096
1245
  />
1097
1246
  </td>
1098
1247
  <td className="px-3 py-3">
1099
- <button type="button" onClick={onOpen} className="flex w-full items-center gap-3 text-left">
1100
- <span aria-hidden className="bg-muted block h-9 w-9 shrink-0 rounded-md" />
1101
- <span className="min-w-0">
1102
- <span className="block truncate text-sm font-medium text-[var(--txt)] group-hover:text-[var(--acc)]">
1103
- {page.title}
1104
- </span>
1105
- <span className="block truncate text-xs text-[var(--sub)]">{page.path}</span>
1248
+ <button type="button" onClick={onOpen} className="block w-full min-w-0 text-left">
1249
+ <span className="block truncate text-sm font-medium text-[var(--txt)] group-hover:text-[var(--acc)]">
1250
+ {page.title}
1106
1251
  </span>
1252
+ <span className="block truncate text-xs text-[var(--sub)]">{page.path}</span>
1107
1253
  </button>
1108
1254
  </td>
1109
- <td className="px-3 py-3">
1110
- <PageTagsCell tagSlugs={page.tags} primaryTagId={page.primaryTagId} resolve={resolveTag} />
1111
- </td>
1112
- <td className="px-3 py-3">
1113
- <div className="flex items-center gap-2">
1114
- <span
1115
- aria-hidden
1116
- className="bg-muted inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-medium text-[var(--sub)]"
1117
- >
1118
- {page.authorInitials}
1119
- </span>
1120
- <span className="truncate text-sm text-[var(--txt)]">{page.authorName}</span>
1121
- </div>
1122
- </td>
1255
+ {showTags && (
1256
+ <td className="px-3 py-3">
1257
+ <PageTagsCell
1258
+ tagSlugs={page.tags}
1259
+ primaryTagId={page.primaryTagId}
1260
+ resolve={resolveTag}
1261
+ />
1262
+ </td>
1263
+ )}
1264
+ {showAuthor && (
1265
+ <td className="px-3 py-3">
1266
+ <div className="flex items-center gap-2">
1267
+ <AuthorAvatar name={page.authorName} size={28} />
1268
+ <span className="truncate text-sm text-[var(--txt)]">{page.authorName}</span>
1269
+ </div>
1270
+ </td>
1271
+ )}
1123
1272
  <td className="px-3 py-3">
1124
1273
  <PostStatusBadge status={page.status} />
1125
1274
  </td>
1126
1275
  <td className="px-3 py-3 text-sm text-[var(--sub)] tabular-nums">
1127
1276
  {formatDateShort(page.lastModified)}
1128
1277
  </td>
1129
- <td className="px-3 py-3">
1130
- <SeoScoreIndicator score={page.seoScore} />
1131
- </td>
1278
+ {showSeo && (
1279
+ <td className="px-3 py-3">
1280
+ <SeoScoreIndicator score={page.seoScore} />
1281
+ </td>
1282
+ )}
1132
1283
  <td className="px-3 py-3">
1133
1284
  <RowActions>
1134
1285
  <RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
@@ -1156,12 +1307,12 @@ function PageRow({
1156
1307
  )
1157
1308
  }
1158
1309
 
1159
- function LoadingRows() {
1310
+ function LoadingRows({ columns }: { columns: number }) {
1160
1311
  return (
1161
1312
  <>
1162
1313
  {Array.from({ length: 8 }).map((_, i) => (
1163
1314
  <tr key={i} className="border-b border-[var(--bdr)] last:border-b-0">
1164
- {Array.from({ length: 8 }).map((__, j) => (
1315
+ {Array.from({ length: columns }).map((__, j) => (
1165
1316
  <td key={j} className="px-3 py-3">
1166
1317
  <div className="bg-muted h-4 w-full animate-pulse rounded" />
1167
1318
  </td>
@@ -1173,17 +1324,19 @@ function LoadingRows() {
1173
1324
  }
1174
1325
 
1175
1326
  function EmptyRow({
1327
+ columns,
1176
1328
  filtersActive,
1177
1329
  onClear,
1178
1330
  onNewPage,
1179
1331
  }: {
1332
+ columns: number
1180
1333
  filtersActive: boolean
1181
1334
  onClear: () => void
1182
1335
  onNewPage: () => void
1183
1336
  }) {
1184
1337
  return (
1185
1338
  <tr>
1186
- <td colSpan={8} className="px-3 py-16 text-center">
1339
+ <td colSpan={columns} className="px-3 py-16 text-center">
1187
1340
  <div className="mb-2 text-sm font-medium text-[var(--txt)]">
1188
1341
  {filtersActive ? 'No pages match your filters' : 'No pages yet'}
1189
1342
  </div>
@@ -17,6 +17,7 @@ import { ChevronDown, Copy, GripVertical, PenLine, Plus, Trash2 } from 'lucide-r
17
17
  import type { Page } from '../../lib/pages-service.js'
18
18
  import type { PageTreeRow } from '../../lib/pages-tree.js'
19
19
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
20
+ import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
20
21
  import { Button } from '../../components/ui/Button.js'
21
22
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
22
23
 
@@ -354,12 +355,7 @@ function TreeRow({
354
355
  </td>
355
356
  <td className="px-3 align-middle">
356
357
  <div className="flex items-center gap-[7px]">
357
- <span
358
- aria-hidden
359
- className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[var(--acc-l)] text-[9px] font-medium text-[var(--acc)]"
360
- >
361
- {page.authorInitials}
362
- </span>
358
+ <AuthorAvatar name={page.authorName} size={22} />
363
359
  <span className="truncate text-[13px] text-[var(--txt)]">{page.authorName}</span>
364
360
  </div>
365
361
  </td>
@@ -33,6 +33,7 @@ import {
33
33
  SeoScoreIndicator,
34
34
  paletteFor,
35
35
  } from '../../components/posts/badges.js'
36
+ import { AuthorAvatar } from '../../components/ui/AuthorAvatar.js'
36
37
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
37
38
  import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
38
39
 
@@ -812,12 +813,7 @@ function PostRow({
812
813
  </td>
813
814
  <td className="px-3 py-3">
814
815
  <div className="flex items-center gap-2">
815
- <span
816
- aria-hidden
817
- className="bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-medium"
818
- >
819
- {post.authorInitials}
820
- </span>
816
+ <AuthorAvatar name={post.authorName} size={28} />
821
817
  <span className="text-foreground truncate text-sm">{post.authorName}</span>
822
818
  </div>
823
819
  </td>
@@ -371,8 +371,9 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
371
371
  </button>
372
372
  </div>
373
373
  ) : (
374
- <div className="overflow-hidden rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
375
- <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">
376
377
  <thead>
377
378
  <tr className="bg-muted border-b border-[var(--bdr)]">
378
379
  <th className="w-9 px-3 py-3">
@@ -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">