@actuate-media/cms-admin 0.71.1 → 0.72.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 (66) hide show
  1. package/CHANGELOG.md +24 -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__/lib/cv.test.js +9 -0
  7. package/dist/__tests__/lib/cv.test.js.map +1 -1
  8. package/dist/__tests__/views/page-section-editor.test.js +11 -0
  9. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  10. package/dist/__tests__/views/pages-list-view.test.js +60 -0
  11. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  12. package/dist/__tests__/views/post-section-editor.test.js +14 -0
  13. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/ui/AuthorAvatar.d.ts +11 -0
  16. package/dist/components/ui/AuthorAvatar.d.ts.map +1 -0
  17. package/dist/components/ui/AuthorAvatar.js +47 -0
  18. package/dist/components/ui/AuthorAvatar.js.map +1 -0
  19. package/dist/components/ui/Modal.d.ts +8 -2
  20. package/dist/components/ui/Modal.d.ts.map +1 -1
  21. package/dist/components/ui/Modal.js +9 -2
  22. package/dist/components/ui/Modal.js.map +1 -1
  23. package/dist/lib/cv.d.ts.map +1 -1
  24. package/dist/lib/cv.js +9 -1
  25. package/dist/lib/cv.js.map +1 -1
  26. package/dist/views/Dashboard.d.ts.map +1 -1
  27. package/dist/views/Dashboard.js +3 -29
  28. package/dist/views/Dashboard.js.map +1 -1
  29. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  30. package/dist/views/Pages/PagesListView.js +82 -12
  31. package/dist/views/Pages/PagesListView.js.map +1 -1
  32. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  33. package/dist/views/Pages/PagesTreeTable.js +2 -1
  34. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  35. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  36. package/dist/views/Posts/PostsListView.js +2 -1
  37. package/dist/views/Posts/PostsListView.js.map +1 -1
  38. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  39. package/dist/views/page-editor/PageSectionEditor.js +4 -0
  40. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  41. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  42. package/dist/views/page-editor/PageSettingsModal.js +8 -2
  43. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  44. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  45. package/dist/views/post-editor/PostFieldsModal.js +4 -2
  46. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  47. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  48. package/dist/views/post-editor/PostSectionEditor.js +5 -1
  49. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/__tests__/components/author-avatar.test.tsx +40 -0
  52. package/src/__tests__/lib/cv.test.ts +10 -0
  53. package/src/__tests__/views/page-section-editor.test.tsx +15 -0
  54. package/src/__tests__/views/pages-list-view.test.tsx +81 -0
  55. package/src/__tests__/views/post-section-editor.test.tsx +26 -0
  56. package/src/components/ui/AuthorAvatar.tsx +69 -0
  57. package/src/components/ui/Modal.tsx +29 -3
  58. package/src/lib/cv.ts +8 -1
  59. package/src/views/Dashboard.tsx +3 -32
  60. package/src/views/Pages/PagesListView.tsx +216 -63
  61. package/src/views/Pages/PagesTreeTable.tsx +2 -6
  62. package/src/views/Posts/PostsListView.tsx +2 -6
  63. package/src/views/page-editor/PageSectionEditor.tsx +4 -0
  64. package/src/views/page-editor/PageSettingsModal.tsx +27 -4
  65. package/src/views/post-editor/PostFieldsModal.tsx +6 -1
  66. package/src/views/post-editor/PostSectionEditor.tsx +6 -1
@@ -41,6 +41,7 @@ import {
41
41
  } from 'lucide-react'
42
42
  import { useApiData } from '../lib/useApiData.js'
43
43
  import { editPathForDoc } from '../lib/collection-routes.js'
44
+ import { authorAvatar } from '../components/ui/AuthorAvatar.js'
44
45
  import { OnboardingBlock, shouldShowOnboarding } from './dashboard/OnboardingBlock.js'
45
46
  import type { OnboardingSignals } from './dashboard/OnboardingBlock.js'
46
47
 
@@ -173,45 +174,15 @@ function todayDateString(): string {
173
174
  })
174
175
  }
175
176
 
176
- // Deterministic colour per author so avatars stay stable across renders
177
- // without storing anything in state or hitting the server.
178
- const AVATAR_COLORS = [
179
- '#7C3AED', // purple
180
- '#E11D48', // rose
181
- '#059669', // emerald
182
- '#0891B2', // cyan
183
- '#D97706', // amber
184
- '#4F46E5', // indigo
185
- '#DC2626', // red
186
- ] as const
187
-
188
- function hashString(s: string): number {
189
- let h = 0
190
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
191
- return Math.abs(h)
192
- }
193
-
194
- function authorAvatar(name: string | null | undefined): { color: string; initials: string } {
195
- const safe = (name ?? '').trim() || 'User'
196
- const parts = safe.split(/\s+/).filter(Boolean)
197
- const initials =
198
- parts.length >= 2
199
- ? `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase()
200
- : safe.slice(0, 2).toUpperCase()
201
- const color = AVATAR_COLORS[hashString(safe) % AVATAR_COLORS.length]!
202
- return { color, initials }
203
- }
204
-
205
177
  /**
206
178
  * Semantic state styling uses theme tokens (`success` / `warning` / `info`
207
179
  * / `brand`) so the dashboard tracks the design system in both light and
208
- * dark mode. Two deliberate exceptions remain as raw values:
180
+ * dark mode. One deliberate exception remains as raw values:
209
181
  * - Error states keep `text-red-*` / `bg-red-*`. The `--destructive`
210
182
  * token's dark value is intentionally dark (low contrast as TEXT on a
211
183
  * dark card), so tokenizing error text here would regress contrast.
212
184
  * Promoting error to a token is a separate, admin-wide change.
213
- * - `AVATAR_COLORS` are dynamic per-author hashes (allowed: truly
214
- * dynamic values, applied via inline style).
185
+ * Author avatar colors moved to the shared `AuthorAvatar` component.
215
186
  */
216
187
  function statusBadge(status: string): {
217
188
  label: string
@@ -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>
@@ -145,6 +145,10 @@ export function PageSectionEditor({
145
145
  if (!documentId) {
146
146
  setPage(emptyPage(initialParentId ?? null))
147
147
  setLoading(false)
148
+ // Start a brand-new page the way WordPress does: name it first. The
149
+ // settings modal opens with the title focused and the slug/path
150
+ // deriving live, so the required fields are done before building.
151
+ setSettingsOpen(true)
148
152
  return
149
153
  }
150
154
 
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
@@ -49,6 +49,9 @@ export function PageSettingsModal({
49
49
  // Auto-derive slug/path from title until the user edits slug manually.
50
50
  const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
51
51
  const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
52
+ // Land the user in the Title field (WordPress-style "name it first"),
53
+ // rather than Radix's default focus on the header close button.
54
+ const titleRef = useRef<HTMLInputElement | null>(null)
52
55
 
53
56
  useEffect(() => {
54
57
  if (!open) return
@@ -118,29 +121,49 @@ export function PageSettingsModal({
118
121
  open={open}
119
122
  onClose={onClose}
120
123
  title="Page settings"
124
+ initialFocusRef={titleRef}
121
125
  actions={
122
126
  <>
123
127
  <Button variant="ghost" onClick={onClose}>
124
128
  Cancel
125
129
  </Button>
126
- <Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
130
+ <Button
131
+ variant="primary"
132
+ form="page-settings-form"
133
+ type="submit"
134
+ disabled={!canEdit || Boolean(slugError)}
135
+ >
127
136
  Done
128
137
  </Button>
129
138
  </>
130
139
  }
131
140
  >
132
- <div className="space-y-3">
141
+ {/* A real form so Enter in any field confirms the dialog — matters
142
+ for the new-page flow where this modal is the first stop. */}
143
+ <form
144
+ id="page-settings-form"
145
+ className="space-y-3"
146
+ onSubmit={(e) => {
147
+ e.preventDefault()
148
+ handleSave()
149
+ }}
150
+ >
133
151
  <div>
134
152
  <label className={LABEL} htmlFor="ps-title">
135
153
  Title <span className="text-destructive">*</span>
136
154
  </label>
137
155
  <input
138
156
  id="ps-title"
157
+ ref={titleRef}
139
158
  className={INPUT}
140
159
  value={title}
141
160
  disabled={!canEdit}
161
+ placeholder="e.g. About Us"
142
162
  onChange={(e) => handleTitle(e.target.value)}
143
163
  />
164
+ <p className="text-muted-foreground mt-1 text-xs">
165
+ The slug and path below follow the title until you edit them.
166
+ </p>
144
167
  </div>
145
168
  <div className="grid grid-cols-2 gap-3">
146
169
  <div>
@@ -240,7 +263,7 @@ export function PageSettingsModal({
240
263
  onChange={(e) => setSeoDescription(e.target.value)}
241
264
  />
242
265
  </div>
243
- </div>
266
+ </form>
244
267
  </Modal>
245
268
  )
246
269
  }