@actuate-media/cms-admin 0.17.0 → 0.19.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 (117) hide show
  1. package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
  4. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
  5. package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
  7. package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
  8. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  10. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  11. package/dist/__tests__/lib/posts-service.test.js +132 -142
  12. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  13. package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
  14. package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/page-section-editor.test.js +56 -0
  16. package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
  17. package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
  18. package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/pages-list-view.test.js +102 -0
  20. package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
  21. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  24. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/index.d.ts +0 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +0 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts +1 -0
  31. package/dist/lib/page-editor-service.d.ts.map +1 -1
  32. package/dist/lib/page-editor-service.js +55 -0
  33. package/dist/lib/page-editor-service.js.map +1 -1
  34. package/dist/lib/pages-service.d.ts +26 -0
  35. package/dist/lib/pages-service.d.ts.map +1 -1
  36. package/dist/lib/pages-service.js +65 -38
  37. package/dist/lib/pages-service.js.map +1 -1
  38. package/dist/lib/post-editor-service.d.ts +6 -4
  39. package/dist/lib/post-editor-service.d.ts.map +1 -1
  40. package/dist/lib/post-editor-service.js +32 -37
  41. package/dist/lib/post-editor-service.js.map +1 -1
  42. package/dist/lib/posts-service.d.ts.map +1 -1
  43. package/dist/lib/posts-service.js +91 -160
  44. package/dist/lib/posts-service.js.map +1 -1
  45. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  46. package/dist/views/Pages/PagesListView.js +71 -46
  47. package/dist/views/Pages/PagesListView.js.map +1 -1
  48. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  49. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  50. package/dist/views/Posts/NewPostPicker.js +5 -4
  51. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  52. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  53. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  54. package/dist/views/Posts/PostTypesView.js.map +1 -1
  55. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  56. package/dist/views/Posts/PostsListView.js +8 -0
  57. package/dist/views/Posts/PostsListView.js.map +1 -1
  58. package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
  59. package/dist/views/page-editor/PagePreview.js +10 -1
  60. package/dist/views/page-editor/PagePreview.js.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  62. package/dist/views/page-editor/PageSectionEditor.js +22 -2
  63. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  64. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  65. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  66. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  67. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  68. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  69. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  70. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  71. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  72. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  73. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  74. package/dist/views/post-editor/PostHeader.js +4 -3
  75. package/dist/views/post-editor/PostHeader.js.map +1 -1
  76. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  77. package/dist/views/post-editor/PostPreview.js +11 -1
  78. package/dist/views/post-editor/PostPreview.js.map +1 -1
  79. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  80. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  81. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  82. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
  85. package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
  86. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  87. package/src/__tests__/lib/posts-service.test.ts +134 -163
  88. package/src/__tests__/views/page-section-editor.test.tsx +65 -0
  89. package/src/__tests__/views/pages-list-view.test.tsx +119 -0
  90. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  91. package/src/index.ts +0 -2
  92. package/src/lib/page-editor-service.ts +61 -0
  93. package/src/lib/pages-service.ts +88 -35
  94. package/src/lib/post-editor-service.ts +41 -40
  95. package/src/lib/posts-service.ts +109 -180
  96. package/src/views/Pages/PagesListView.tsx +113 -82
  97. package/src/views/Posts/NewPostPicker.tsx +5 -4
  98. package/src/views/Posts/PostTypesView.tsx +5 -4
  99. package/src/views/Posts/PostsListView.tsx +9 -0
  100. package/src/views/page-editor/PagePreview.tsx +10 -1
  101. package/src/views/page-editor/PageSectionEditor.tsx +25 -1
  102. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  103. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  104. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  105. package/src/views/post-editor/PostHeader.tsx +4 -4
  106. package/src/views/post-editor/PostPreview.tsx +12 -1
  107. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  108. package/dist/views/PostEditor.d.ts +0 -6
  109. package/dist/views/PostEditor.d.ts.map +0 -1
  110. package/dist/views/PostEditor.js +0 -79
  111. package/dist/views/PostEditor.js.map +0 -1
  112. package/dist/views/Posts.d.ts +0 -5
  113. package/dist/views/Posts.d.ts.map +0 -1
  114. package/dist/views/Posts.js +0 -73
  115. package/dist/views/Posts.js.map +0 -1
  116. package/src/views/PostEditor.tsx +0 -257
  117. package/src/views/Posts.tsx +0 -375
@@ -13,16 +13,19 @@ import {
13
13
  X,
14
14
  } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
16
17
  import {
18
+ applyPagesQuery,
17
19
  bulkChangePageTags,
18
20
  bulkDeletePages,
19
21
  bulkUpdatePages,
20
- fetchPages,
22
+ fetchPagesDataset,
21
23
  groupPages,
24
+ type BulkResult,
22
25
  type Page,
23
26
  type PageGroupBy,
27
+ type PagesDataset,
24
28
  type PageStatus,
25
- type PagesResult,
26
29
  type PageTag,
27
30
  } from '../../lib/pages-service.js'
28
31
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
@@ -61,7 +64,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
61
64
  const [page, setPage] = useState(1)
62
65
  const [selected, setSelected] = useState<Set<string>>(new Set())
63
66
 
64
- const [data, setData] = useState<PagesResult | null>(null)
67
+ const [dataset, setDataset] = useState<PagesDataset | null>(null)
65
68
  const [loading, setLoading] = useState(true)
66
69
  const [error, setError] = useState<string | null>(null)
67
70
  const [refreshToken, setRefreshToken] = useState(0)
@@ -81,23 +84,17 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
81
84
 
82
85
  const tagIds = useMemo(() => [...selectedTags], [selectedTags])
83
86
 
87
+ // Single network read. Search / sort / group / paging are applied in
88
+ // memory below, so changing any of those never re-fetches. The dataset
89
+ // refreshes only on mount and after a mutation (refreshToken bump).
84
90
  useEffect(() => {
85
91
  let cancelled = false
86
92
  setLoading(true)
87
93
  setError(null)
88
- fetchPages({
89
- status,
90
- tagIds,
91
- search: debouncedSearch,
92
- groupBy,
93
- sortBy,
94
- sortDirection: dir,
95
- page,
96
- pageSize: PAGE_SIZE,
97
- })
94
+ fetchPagesDataset()
98
95
  .then((res) => {
99
96
  if (cancelled) return
100
- setData(res)
97
+ setDataset(res)
101
98
  setLoading(false)
102
99
  })
103
100
  .catch((err: unknown) => {
@@ -108,21 +105,40 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
108
105
  return () => {
109
106
  cancelled = true
110
107
  }
111
- }, [status, tagIds, debouncedSearch, groupBy, sortBy, dir, page, refreshToken])
108
+ }, [refreshToken])
112
109
 
113
- // Reset paging/selection when the result set changes shape.
110
+ // Reset paging/selection when the active filters change shape.
114
111
  useEffect(() => {
115
112
  setPage(1)
116
113
  setSelected(new Set())
117
114
  }, [debouncedSearch, status, tagIds, groupBy])
118
115
 
119
- const pages = data?.pages ?? []
120
- const tags = data?.tags ?? []
121
- const total = data?.total ?? 0
122
- const totalAll = useMemo(() => tags.reduce((sum, t) => sum + t.pageCount, 0), [tags])
123
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
116
+ const allPages = useMemo(() => dataset?.pages ?? [], [dataset])
117
+ const tags = useMemo(() => dataset?.tags ?? [], [dataset])
118
+ const grandTotal = allPages.length
124
119
  const grouped = groupBy !== 'none'
125
120
 
121
+ const filtered = useMemo(
122
+ () =>
123
+ applyPagesQuery(allPages, {
124
+ status,
125
+ tagIds,
126
+ search: debouncedSearch,
127
+ sortBy,
128
+ sortDirection: dir,
129
+ }),
130
+ [allPages, status, tagIds, debouncedSearch, sortBy, dir],
131
+ )
132
+ const total = filtered.length
133
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
134
+
135
+ // Pagination only applies to the flat view; grouped views render every
136
+ // match so the buckets stay complete.
137
+ const pages = useMemo(
138
+ () => (grouped ? filtered : filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)),
139
+ [filtered, grouped, page],
140
+ )
141
+
126
142
  const tagBySlug = useMemo(() => {
127
143
  const m = new Map<string, PageTag>()
128
144
  for (const t of tags) m.set(t.slug, t)
@@ -143,6 +159,11 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
143
159
  [sortBy],
144
160
  )
145
161
 
162
+ // `aria-sort` belongs on the column header cell (`<th>`), not the button
163
+ // inside it — assistive tech reads sort state from the header.
164
+ const ariaSortFor = (field: SortBy): 'ascending' | 'descending' | 'none' =>
165
+ sortBy === field ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
166
+
146
167
  const filtersAreActive = debouncedSearch !== '' || status !== 'all' || selectedTags.size > 0
147
168
 
148
169
  const clearFilters = () => {
@@ -190,17 +211,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
190
211
  setRefreshToken((t) => t + 1)
191
212
  }
192
213
 
214
+ // Bulk service calls always resolve a { ok, failed } tally (they never
215
+ // reject), so we promote a *total* failure to a rejection — that's what
216
+ // makes `toast.promise`'s `error` branch reachable. The `await` is
217
+ // wrapped so a rejection can't escape as an unhandled promise, and
218
+ // `afterBulk` always runs to refresh the (possibly partially-changed) set.
219
+ const allFailed = (r: BulkResult) => r.ok === 0 && r.failed > 0
220
+
193
221
  const runBulkStatus = async (target: PageStatus) => {
194
222
  const ids = selectedPages.map((p) => p.id)
195
223
  if (ids.length === 0) return
196
224
  const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
197
- const promise = bulkUpdatePages(ids, { status: target })
225
+ const promise = bulkUpdatePages(ids, { status: target }).then((r) => {
226
+ if (allFailed(r)) throw new Error('Bulk update failed')
227
+ return r
228
+ })
198
229
  toast.promise(promise, {
199
230
  loading: `${verb} ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
200
231
  success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
201
232
  error: 'Bulk update failed',
202
233
  })
203
- await promise
234
+ try {
235
+ await promise
236
+ } catch {
237
+ /* surfaced via toast.promise */
238
+ }
204
239
  afterBulk()
205
240
  }
206
241
 
@@ -209,7 +244,10 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
209
244
  const promise = bulkChangePageTags(
210
245
  selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
211
246
  mode === 'add' ? { add: [slug] } : { remove: [slug] },
212
- )
247
+ ).then((r) => {
248
+ if (allFailed(r)) throw new Error('Bulk tag change failed')
249
+ return r
250
+ })
213
251
  const tagName = tagBySlug.get(slug)?.name ?? slug
214
252
  toast.promise(promise, {
215
253
  loading: `${mode === 'add' ? 'Adding' : 'Removing'} "${tagName}"…`,
@@ -217,20 +255,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
217
255
  `${r.ok} page${r.ok === 1 ? '' : 's'} updated${r.failed ? `, ${r.failed} failed` : ''}`,
218
256
  error: 'Bulk tag change failed',
219
257
  })
220
- await promise
258
+ try {
259
+ await promise
260
+ } catch {
261
+ /* surfaced via toast.promise */
262
+ }
221
263
  afterBulk()
222
264
  }
223
265
 
224
266
  const runBulkDelete = async () => {
225
267
  const ids = deletablePages.map((p) => p.id)
226
268
  if (ids.length === 0) return
227
- const promise = bulkDeletePages(ids)
269
+ const promise = bulkDeletePages(ids).then((r) => {
270
+ if (allFailed(r)) throw new Error('Bulk delete failed')
271
+ return r
272
+ })
228
273
  toast.promise(promise, {
229
274
  loading: `Deleting ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
230
275
  success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
231
276
  error: 'Bulk delete failed',
232
277
  })
233
- await promise
278
+ try {
279
+ await promise
280
+ } catch {
281
+ /* surfaced via toast.promise */
282
+ }
234
283
  afterBulk()
235
284
  }
236
285
 
@@ -242,7 +291,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
242
291
  <div className="flex flex-col gap-1">
243
292
  <h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">Pages</h1>
244
293
  <p className="text-muted-foreground text-sm">
245
- {totalAll} page{totalAll === 1 ? '' : 's'} · organized by tags
294
+ {grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
246
295
  </p>
247
296
  </div>
248
297
 
@@ -291,7 +340,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
291
340
  />
292
341
 
293
342
  <span className="text-muted-foreground ml-auto text-sm tabular-nums">
294
- {total} of {totalAll}
343
+ {total} of {grandTotal}
295
344
  </span>
296
345
  </div>
297
346
 
@@ -369,7 +418,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
369
418
  className="border-border accent-primary rounded"
370
419
  />
371
420
  </th>
372
- <th scope="col" className="px-3 py-2.5 text-left">
421
+ <th scope="col" aria-sort={ariaSortFor('title')} className="px-3 py-2.5 text-left">
373
422
  <SortHeader
374
423
  label="Title"
375
424
  field="title"
@@ -381,7 +430,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
381
430
  <th scope="col" className="px-3 py-2.5 text-left">
382
431
  <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
383
432
  </th>
384
- <th scope="col" className="px-3 py-2.5 text-left">
433
+ <th scope="col" aria-sort={ariaSortFor('author')} className="px-3 py-2.5 text-left">
385
434
  <SortHeader
386
435
  label="Author"
387
436
  field="author"
@@ -390,7 +439,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
390
439
  onSort={handleSort}
391
440
  />
392
441
  </th>
393
- <th scope="col" className="px-3 py-2.5 text-left">
442
+ <th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
394
443
  <SortHeader
395
444
  label="Status"
396
445
  field="status"
@@ -399,7 +448,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
399
448
  onSort={handleSort}
400
449
  />
401
450
  </th>
402
- <th scope="col" className="px-3 py-2.5 text-left">
451
+ <th scope="col" aria-sort={ariaSortFor('modified')} className="px-3 py-2.5 text-left">
403
452
  <SortHeader
404
453
  label="Last Modified"
405
454
  field="modified"
@@ -408,7 +457,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
408
457
  onSort={handleSort}
409
458
  />
410
459
  </th>
411
- <th scope="col" className="px-3 py-2.5 text-left">
460
+ <th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
412
461
  <SortHeader
413
462
  label="SEO"
414
463
  field="seo"
@@ -513,12 +562,10 @@ function SortHeader({
513
562
  onSort: (f: SortBy) => void
514
563
  }) {
515
564
  const active = current === field
516
- const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
517
565
  return (
518
566
  <button
519
567
  type="button"
520
568
  onClick={() => onSort(field)}
521
- aria-sort={ariaSort}
522
569
  className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
523
570
  active ? 'text-foreground' : 'text-muted-foreground'
524
571
  }`}
@@ -680,56 +727,40 @@ function TagMenu({
680
727
  tags: PageTag[]
681
728
  onPick: (slug: string) => void
682
729
  }) {
683
- const [open, setOpen] = useState(false)
684
- const ref = useRef<HTMLDivElement>(null)
685
-
686
- useEffect(() => {
687
- if (!open) return
688
- const onDoc = (e: MouseEvent) => {
689
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
690
- }
691
- document.addEventListener('mousedown', onDoc)
692
- return () => document.removeEventListener('mousedown', onDoc)
693
- }, [open])
694
-
695
730
  return (
696
- <div className="relative" ref={ref}>
697
- <button
698
- type="button"
699
- onClick={() => setOpen((o) => !o)}
700
- aria-haspopup="menu"
701
- aria-expanded={open}
702
- className="text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
703
- >
704
- {label}
705
- <ChevronDown className="h-3 w-3" aria-hidden />
706
- </button>
707
- {open && (
708
- <div
709
- role="menu"
710
- className="border-border bg-popover absolute z-20 mt-1 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-lg"
731
+ <DropdownMenu.Root>
732
+ <DropdownMenu.Trigger asChild>
733
+ <button
734
+ type="button"
735
+ className="text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
711
736
  >
712
- {tags.length === 0 && (
737
+ {label}
738
+ <ChevronDown className="h-3 w-3" aria-hidden />
739
+ </button>
740
+ </DropdownMenu.Trigger>
741
+ <DropdownMenu.Portal>
742
+ <DropdownMenu.Content
743
+ align="start"
744
+ sideOffset={4}
745
+ className="border-border bg-popover z-50 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-md"
746
+ >
747
+ {tags.length === 0 ? (
713
748
  <div className="text-muted-foreground px-2 py-1.5 text-xs">No tags yet</div>
749
+ ) : (
750
+ tags.map((t) => (
751
+ <DropdownMenu.Item
752
+ key={t.slug}
753
+ onSelect={() => onPick(t.slug)}
754
+ className="text-foreground focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
755
+ >
756
+ <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
757
+ {t.name}
758
+ </DropdownMenu.Item>
759
+ ))
714
760
  )}
715
- {tags.map((t) => (
716
- <button
717
- key={t.slug}
718
- type="button"
719
- role="menuitem"
720
- onClick={() => {
721
- onPick(t.slug)
722
- setOpen(false)
723
- }}
724
- className="text-foreground hover:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
725
- >
726
- <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
727
- {t.name}
728
- </button>
729
- ))}
730
- </div>
731
- )}
732
- </div>
761
+ </DropdownMenu.Content>
762
+ </DropdownMenu.Portal>
763
+ </DropdownMenu.Root>
733
764
  )
734
765
  }
735
766
 
@@ -9,10 +9,11 @@ import { resolvePostTypeIcon } from '../../components/posts/icons.js'
9
9
  /**
10
10
  * Type-picker landing page for "+ New Post".
11
11
  *
12
- * When zero or one post type is configured we'd ordinarily auto-route
13
- * to the per-collection editor, but the spec calls out always showing
14
- * a chooser when "+ New Post" is clicked. We keep that behavior so
15
- * the experience is consistent regardless of how many types exist.
12
+ * Behavior by number of configured post types:
13
+ * - exactly one → skip the picker and forward straight to that type's
14
+ * new-post editor (a one-card chooser adds nothing but a flash)
15
+ * - two or more → show the chooser cards
16
+ * - zero → show a "no post types configured" setup hint
16
17
  *
17
18
  * Picking a card navigates to the section-based Post editor's new-post
18
19
  * route (`/posts/<slug>/new`), which seeds the post from that type's
@@ -14,10 +14,11 @@ import { resolvePostTypeIcon } from '../../components/posts/icons.js'
14
14
  * - title + description
15
15
  * - footer row: post count · field count · URL base
16
16
  *
17
- * Cards open the per-collection settings (`/collections/:slug/settings`
18
- * in the existing admin) when clicked. The dashed "+ New Post Type"
19
- * card routes to /posts/types/new where users get instructions for
20
- * adding a new collection to the config file.
17
+ * Clicking a card opens that type's settings (`/posts/types/:slug/edit`);
18
+ * each card also has an "Edit Template" action (`/posts/types/:slug/template`)
19
+ * for the type's default layout. The dashed "+ New Post Type" card routes to
20
+ * `/posts/types/new`, where users get instructions for adding a new collection
21
+ * to the config file.
21
22
  *
22
23
  * Why no in-app create/edit flow yet:
23
24
  * Collections are file-defined (actuate.config.ts). Writing to that
@@ -118,6 +118,15 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
118
118
  setSelected(new Set())
119
119
  }, [activeType, debouncedSearch, status, author])
120
120
 
121
+ // Selection is scoped to the rows visible on the current page: bulk
122
+ // actions only ever touch `selectedPosts` (the current page's selected
123
+ // rows), so clear the selection whenever the page changes. Without this
124
+ // the bulk bar count keeps counting rows from earlier pages while the
125
+ // publish/delete handlers silently operate on nothing.
126
+ useEffect(() => {
127
+ setSelected(new Set())
128
+ }, [page])
129
+
121
130
  const total = data?.total ?? 0
122
131
  const totalAll = useMemo(
123
132
  () => (data?.types ?? []).reduce((sum, t) => sum + t.postCount, 0),
@@ -62,7 +62,14 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
62
62
  <div className="bg-card border-border flex h-12 shrink-0 items-center justify-between border-b px-4">
63
63
  <button
64
64
  type="button"
65
- onClick={() => window.close()}
65
+ onClick={() => {
66
+ // Opened via the editor's "Preview" (script-opened tab) → close
67
+ // it. Opened by navigating to the URL directly (no opener, so
68
+ // `window.close()` is a no-op) → fall back to the editor so the
69
+ // button is never dead.
70
+ if (window.opener) window.close()
71
+ else onNavigate(`/pages/${documentId}/edit`)
72
+ }}
66
73
  className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm"
67
74
  aria-label="Close preview tab"
68
75
  >
@@ -82,6 +89,8 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
82
89
  </button>
83
90
  </div>
84
91
 
92
+ {/* The canvas is intentionally white regardless of the admin theme:
93
+ it emulates the public page surface, not admin chrome. */}
85
94
  <div className="flex-1 overflow-auto bg-white">
86
95
  {visible.length === 0 ? (
87
96
  <div className="text-muted-foreground flex h-full items-center justify-center text-sm">
@@ -11,6 +11,7 @@ import {
11
11
  duplicateSection,
12
12
  emptyPage,
13
13
  fetchPageForEditor,
14
+ missingPageFields,
14
15
  moveSection,
15
16
  publishPage,
16
17
  removeSection,
@@ -88,12 +89,35 @@ export function PageSectionEditor({
88
89
 
89
90
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
90
91
 
92
+ // A misconfigured `pages` collection (missing the fields the editor
93
+ // writes) would silently drop content on save — detect it up front.
94
+ // Memoized to a string/null so the load effect can depend on it without
95
+ // re-running when `config`'s object identity changes between renders.
96
+ const schemaError = useMemo(() => {
97
+ const missing = missingPageFields(config)
98
+ if (missing.length === 0) return null
99
+ const plural = missing.length > 1
100
+ return (
101
+ `The "pages" collection is missing required field${plural ? 's' : ''}: ` +
102
+ `${missing.join(', ')}. Declare ${plural ? 'them' : 'it'} in your CMS config ` +
103
+ `so the page editor can save without losing content.`
104
+ )
105
+ }, [config])
106
+
91
107
  // ─── Load ──────────────────────────────────────────────────────────
92
108
  useEffect(() => {
93
109
  let cancelled = false
94
110
  setLoading(true)
95
111
  setLoadError(null)
96
112
 
113
+ // Fail loud on a misconfigured `pages` collection rather than letting
114
+ // the user build a page whose content would be silently dropped on save.
115
+ if (schemaError) {
116
+ setLoadError(schemaError)
117
+ setLoading(false)
118
+ return
119
+ }
120
+
97
121
  if (!documentId) {
98
122
  setPage(emptyPage())
99
123
  setLoading(false)
@@ -119,7 +143,7 @@ export function PageSectionEditor({
119
143
  return () => {
120
144
  cancelled = true
121
145
  }
122
- }, [documentId])
146
+ }, [documentId, schemaError])
123
147
 
124
148
  // ─── Unsaved-changes guard (browser unload) ──────────────────────────
125
149
  useEffect(() => {
@@ -4,8 +4,12 @@ import { bool, str, type PostRenderContext } from './parts.js'
4
4
  /**
5
5
  * Renders the post's main body. When `source === 'field'` it shows the
6
6
  * post's own Body field (passed via context); otherwise it renders the
7
- * section's own `body` copy. Body text is treated as plain paragraphs split
8
- * on blank lines — the renderer never injects raw HTML.
7
+ * section's own `body` copy.
8
+ *
9
+ * The Body field is a `richText` field (sanitized HTML on write), so this
10
+ * canvas preview renders it as HTML — exactly like the public site's
11
+ * article-body renderer — to stay WYSIWYG. It previews the author's own
12
+ * in-progress draft; the server sanitizes (sanitize-html) on save.
9
13
  */
10
14
  export function ArticleBodySection({
11
15
  section,
@@ -16,15 +20,10 @@ export function ArticleBodySection({
16
20
  }) {
17
21
  const c = section.content
18
22
  const source = str(c, 'source', 'field')
19
- const raw = source === 'field' ? (context?.body ?? '') : str(c, 'body')
23
+ const html = source === 'field' ? (context?.body ?? '') : str(c, 'body')
20
24
  const wide = str(c, 'width', 'normal') === 'wide'
21
25
  const showToc = bool(c, 'showToc')
22
26
 
23
- const paragraphs = raw
24
- .split(/\n{2,}/)
25
- .map((p) => p.trim())
26
- .filter(Boolean)
27
-
28
27
  return (
29
28
  <div className={`mx-auto px-6 ${wide ? 'max-w-4xl' : 'max-w-2xl'}`}>
30
29
  {showToc && (
@@ -37,14 +36,11 @@ export function ArticleBodySection({
37
36
  </p>
38
37
  </div>
39
38
  )}
40
- {paragraphs.length > 0 ? (
41
- <div className="space-y-4">
42
- {paragraphs.map((p, i) => (
43
- <p key={i} className="text-base leading-relaxed text-zinc-700">
44
- {p}
45
- </p>
46
- ))}
47
- </div>
39
+ {html.trim() ? (
40
+ <div
41
+ className="prose prose-zinc max-w-none text-base leading-relaxed text-zinc-700"
42
+ dangerouslySetInnerHTML={{ __html: html }}
43
+ />
48
44
  ) : (
49
45
  <p className="text-base leading-relaxed text-zinc-400 italic">
50
46
  {source === 'field'
@@ -102,6 +102,12 @@ export function PostEditorCanvas({
102
102
  }
103
103
 
104
104
  function EmptyBody({ onAddSection }: { onAddSection: () => void }) {
105
+ // This block sits inside the fixed-white preview surface, so the neutral
106
+ // greys intentionally match the website-preview palette used by PostHeader
107
+ // and the section renderers (not the admin theme tokens, which flip in dark
108
+ // mode and would lose contrast on the always-white surface). The CTA is the
109
+ // one true admin affordance here, so it uses the brand `primary` token,
110
+ // which carries a guaranteed-contrast foreground in both modes.
105
111
  return (
106
112
  <div className="flex flex-col items-center justify-center gap-3 border-t border-zinc-100 px-6 py-16 text-center">
107
113
  <span className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100">
@@ -113,7 +119,7 @@ function EmptyBody({ onAddSection }: { onAddSection: () => void }) {
113
119
  <button
114
120
  type="button"
115
121
  onClick={onAddSection}
116
- className="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
122
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
117
123
  >
118
124
  <Plus className="h-4 w-4" aria-hidden />
119
125
  Add Section
@@ -3,6 +3,8 @@
3
3
  import { useEffect, useState } from 'react'
4
4
  import { Modal } from '../../components/ui/Modal.js'
5
5
  import { Button } from '../../components/ui/Button.js'
6
+ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
7
+ import { RichTextField } from '../../fields/RichTextField.js'
6
8
  import type { EditorPost } from '../../lib/post-editor-service.js'
7
9
 
8
10
  interface PostFieldsModalProps {
@@ -36,6 +38,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
36
38
  const [seoTitle, setSeoTitle] = useState(post.seoTitle)
37
39
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
38
40
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
41
+ const [mediaOpen, setMediaOpen] = useState(false)
39
42
 
40
43
  useEffect(() => {
41
44
  if (!open) return
@@ -134,16 +137,26 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
134
137
  </div>
135
138
  <div>
136
139
  <label className={LABEL} htmlFor="pf-image">
137
- Featured image URL
140
+ Featured image
138
141
  </label>
139
- <input
140
- id="pf-image"
141
- className={INPUT}
142
- value={featuredImage}
143
- disabled={!canEdit}
144
- placeholder="https://…"
145
- onChange={(e) => setFeaturedImage(e.target.value)}
146
- />
142
+ <div className="flex gap-2">
143
+ <input
144
+ id="pf-image"
145
+ className={`${INPUT} flex-1`}
146
+ value={featuredImage}
147
+ disabled={!canEdit}
148
+ placeholder="https://… or pick from the media library"
149
+ onChange={(e) => setFeaturedImage(e.target.value)}
150
+ />
151
+ <button
152
+ type="button"
153
+ disabled={!canEdit}
154
+ onClick={() => setMediaOpen(true)}
155
+ className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
156
+ >
157
+ Browse
158
+ </button>
159
+ </div>
147
160
  {featuredImage && (
148
161
  <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
149
162
  <img src={featuredImage} alt="" className="h-28 w-full object-cover" />
@@ -151,20 +164,12 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
151
164
  )}
152
165
  </div>
153
166
  <div>
154
- <label className={LABEL} htmlFor="pf-body">
155
- Body
156
- </label>
157
- <textarea
158
- id="pf-body"
159
- className={`${INPUT} min-h-[120px] resize-y`}
167
+ <RichTextField
168
+ label="Body"
160
169
  value={body}
161
- disabled={!canEdit}
162
- placeholder="The main post content. Shown by the Article Body section."
163
- onChange={(e) => setBody(e.target.value)}
170
+ onChange={canEdit ? setBody : () => {}}
171
+ helpText="Rendered by the Article Body section when its source is set to the post Body field."
164
172
  />
165
- <p className="text-muted-foreground mt-1 text-xs">
166
- Rendered by the Article Body section when its source is set to the post Body field.
167
- </p>
168
173
  </div>
169
174
  <div>
170
175
  <label className={LABEL} htmlFor="pf-seo-title">
@@ -191,6 +196,16 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
191
196
  />
192
197
  </div>
193
198
  </div>
199
+
200
+ <MediaPickerModal
201
+ open={mediaOpen}
202
+ onClose={() => setMediaOpen(false)}
203
+ onSelect={(url) => {
204
+ setFeaturedImage(url)
205
+ setMediaOpen(false)
206
+ }}
207
+ accept="image/*"
208
+ />
194
209
  </Modal>
195
210
  )
196
211
  }