@actuate-media/cms-admin 0.18.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 (75) hide show
  1. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  2. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  3. package/dist/__tests__/lib/posts-service.test.js +132 -142
  4. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  5. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  8. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/index.d.ts +0 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +0 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/post-editor-service.d.ts +6 -4
  15. package/dist/lib/post-editor-service.d.ts.map +1 -1
  16. package/dist/lib/post-editor-service.js +32 -37
  17. package/dist/lib/post-editor-service.js.map +1 -1
  18. package/dist/lib/posts-service.d.ts.map +1 -1
  19. package/dist/lib/posts-service.js +91 -160
  20. package/dist/lib/posts-service.js.map +1 -1
  21. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  22. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  23. package/dist/views/Posts/NewPostPicker.js +5 -4
  24. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  25. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  26. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  27. package/dist/views/Posts/PostTypesView.js.map +1 -1
  28. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  29. package/dist/views/Posts/PostsListView.js +8 -0
  30. package/dist/views/Posts/PostsListView.js.map +1 -1
  31. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  32. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  33. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  34. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  35. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  37. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  38. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  39. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  40. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  41. package/dist/views/post-editor/PostHeader.js +4 -3
  42. package/dist/views/post-editor/PostHeader.js.map +1 -1
  43. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostPreview.js +11 -1
  45. package/dist/views/post-editor/PostPreview.js.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  47. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  48. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  49. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  52. package/src/__tests__/lib/posts-service.test.ts +134 -163
  53. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  54. package/src/index.ts +0 -2
  55. package/src/lib/post-editor-service.ts +41 -40
  56. package/src/lib/posts-service.ts +109 -180
  57. package/src/views/Posts/NewPostPicker.tsx +5 -4
  58. package/src/views/Posts/PostTypesView.tsx +5 -4
  59. package/src/views/Posts/PostsListView.tsx +9 -0
  60. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  61. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  62. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  63. package/src/views/post-editor/PostHeader.tsx +4 -4
  64. package/src/views/post-editor/PostPreview.tsx +12 -1
  65. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  66. package/dist/views/PostEditor.d.ts +0 -6
  67. package/dist/views/PostEditor.d.ts.map +0 -1
  68. package/dist/views/PostEditor.js +0 -79
  69. package/dist/views/PostEditor.js.map +0 -1
  70. package/dist/views/Posts.d.ts +0 -5
  71. package/dist/views/Posts.d.ts.map +0 -1
  72. package/dist/views/Posts.js +0 -73
  73. package/dist/views/Posts.js.map +0 -1
  74. package/src/views/PostEditor.tsx +0 -257
  75. package/src/views/Posts.tsx +0 -375
@@ -34,7 +34,7 @@ import {
34
34
  updateSectionMeta,
35
35
  coerceSections,
36
36
  coercePostHeader,
37
- defaultPostHeader,
37
+ createPostTemplate,
38
38
  validateSectionContent,
39
39
  type PageSection,
40
40
  type PostHeaderConfig,
@@ -185,59 +185,60 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
185
185
 
186
186
  // ─── Templates ───────────────────────────────────────────────────────
187
187
 
188
- interface ListEnvelope {
189
- docs: DocumentApiRow[]
190
- total: number
188
+ /**
189
+ * Shape returned by the dedicated `/post-templates/:type` endpoint
190
+ * (`{ data: { postType, docId, header, sections } }`, unwrapped by cmsApi).
191
+ */
192
+ interface TemplateApiResponse {
193
+ postType: string
194
+ docId: string | null
195
+ header: unknown
196
+ sections: unknown
197
+ }
198
+
199
+ function templateEndpoint(postType: string): string {
200
+ return `/post-templates/${encodeURIComponent(postType)}`
191
201
  }
192
202
 
193
203
  /**
194
- * Load a post type's template. Looks up the single `post-templates` document
195
- * whose slug matches the post type. Returns an unsaved default (centered
196
- * header, empty section list) when the type has no template yet.
204
+ * Load a post type's template via the dedicated, audited `/post-templates/:type`
205
+ * endpoint (scoped + logged), rather than the raw collection API. When the type
206
+ * has no saved template yet the endpoint returns the default starting template;
207
+ * if the request fails outright we still hand back that default so the editor
208
+ * is never blank.
197
209
  */
198
210
  export async function fetchTemplateForEditor(postType: string): Promise<EditorPostTemplate> {
199
- const res = await cmsApi<ListEnvelope>(`/collections/${TEMPLATES_COLLECTION}?pageSize=200`)
200
- const docs = res.data?.docs ?? []
201
- const match = docs.find((d) => (d.slug ?? d.data?.slug) === postType)
202
- if (!match) {
203
- return { docId: null, postType, header: defaultPostHeader(), sections: [] }
211
+ const res = await cmsApi<TemplateApiResponse>(templateEndpoint(postType))
212
+ if (res.error || !res.data) {
213
+ const seed = createPostTemplate(postType)
214
+ return { docId: null, postType, header: seed.header, sections: seed.sections }
204
215
  }
205
- const data = match.data ?? {}
206
- return {
207
- docId: match.id,
208
- postType,
209
- header: coercePostHeader(data.header),
210
- sections: coerceSections(data.sections),
216
+ const r = res.data
217
+ const docId = r.docId ?? null
218
+ const header = coercePostHeader(r.header)
219
+ let sections = coerceSections(r.sections)
220
+ // Defend against an older server (or an empty stored template): an unsaved
221
+ // type should still seed the default sections so authors aren't faced with a
222
+ // blank canvas.
223
+ if (!docId && sections.length === 0) {
224
+ sections = createPostTemplate(postType).sections
211
225
  }
226
+ return { docId, postType, header, sections }
212
227
  }
213
228
 
214
- function templateWriteBody(template: EditorPostTemplate, extra?: Record<string, unknown>): string {
215
- return JSON.stringify({
216
- title: `${template.postType} template`,
217
- slug: template.postType,
218
- postType: template.postType,
219
- header: template.header,
220
- sections: template.sections,
221
- ...extra,
222
- })
223
- }
224
-
225
- /** Persist a post type's template (creates the doc on first save). */
229
+ /** Persist a post type's template via `PUT /post-templates/:type`. */
226
230
  export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
227
- const endpoint = template.docId
228
- ? `/collections/${TEMPLATES_COLLECTION}/${template.docId}`
229
- : `/collections/${TEMPLATES_COLLECTION}`
230
- const res = await cmsApi<DocumentApiRow>(endpoint, {
231
- method: template.docId ? 'PUT' : 'POST',
232
- body: templateWriteBody(template, { status: 'PUBLISHED' }),
231
+ const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
232
+ method: 'PUT',
233
+ body: JSON.stringify({ header: template.header, sections: template.sections }),
233
234
  })
234
235
  if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
235
- const data = res.data.data ?? {}
236
+ const r = res.data
236
237
  return {
237
- docId: res.data.id,
238
+ docId: r.docId ?? null,
238
239
  postType: template.postType,
239
- header: coercePostHeader(data.header),
240
- sections: coerceSections(data.sections),
240
+ header: coercePostHeader(r.header),
241
+ sections: coerceSections(r.sections),
241
242
  }
242
243
  }
243
244
 
@@ -1,25 +1,16 @@
1
1
  /**
2
- * Posts service — thin client over the existing REST endpoints,
3
- * shaped around the data the Posts admin area needs.
2
+ * Posts service — thin client over the CMS REST endpoints, shaped around the
3
+ * data the Posts admin area needs.
4
4
  *
5
- * V1 strategy: aggregate across post-typed collections client-side
6
- * (one `/collections/:slug` call per type) and join authors via
7
- * `/users`. Each view component pulls from `fetchPosts()` so when
8
- * a server-side `/api/cms/posts` endpoint lands later, only this
9
- * file changes callers consume the same `Post[]` / `PostType[]`
10
- * shapes.
5
+ * `fetchPosts` calls the server-side aggregate endpoint `GET /posts`, which
6
+ * does the cross-collection filter / sort / paginate at the DB level (one
7
+ * query + one count) and returns the post rows alongside dataset-wide type
8
+ * counts and the contributor list. This keeps the Posts list O(page) instead
9
+ * of pulling every document per type into the browser, and lets it scale past
10
+ * the old ~500-row-per-type client cap.
11
11
  *
12
- * Why not a single `/collections/all` call?
13
- * - Each collection has its own access-control check, so listing
14
- * across collections must respect per-collection `read` rules.
15
- * The existing endpoint enforces this per-call; mixing them
16
- * server-side would need a new auth boundary.
17
- * - V1 traffic is small (the seed is 15 rows); a few extra
18
- * parallel requests are well under the rate-limit budget.
19
- *
20
- * Promote to a single endpoint when:
21
- * - Any post-type collection grows past ~5k rows, OR
22
- * - Page-load latency exceeds 300ms in the admin metrics.
12
+ * Access control still lives server-side: the endpoint only returns rows from
13
+ * collections the caller's scope can read.
23
14
  */
24
15
  import { cmsApi } from './api.js'
25
16
 
@@ -133,204 +124,142 @@ function collectionToPostType(c: CollectionApiRow, count = 0): PostType {
133
124
  }
134
125
  }
135
126
 
136
- // ─── Users → Authors ─────────────────────────────────────────────────
127
+ // ─── Authors ─────────────────────────────────────────────────────────
137
128
 
138
- interface UserApiRow {
139
- id: string
140
- name?: string | null
141
- email: string
129
+ function initialsFromName(name: string | null | undefined): string {
130
+ const trimmed = name?.trim()
131
+ if (!trimmed) return '?'
132
+ const parts = trimmed.split(/\s+/)
133
+ const first = parts[0]?.[0] ?? ''
134
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
135
+ return (first + last).toUpperCase() || '?'
142
136
  }
143
137
 
144
- function initialsFromName(name: string | null | undefined, email: string): string {
145
- if (name && name.trim().length > 0) {
146
- const parts = name.trim().split(/\s+/)
147
- const first = parts[0]?.[0] ?? ''
148
- const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
149
- return (first + last).toUpperCase() || (email[0] ?? '?').toUpperCase()
150
- }
151
- return (email[0] ?? '?').toUpperCase()
152
- }
138
+ // ─── Posts (server-side aggregate) ───────────────────────────────────
153
139
 
154
- async function fetchAuthorsMap(): Promise<Map<string, PostAuthor>> {
155
- const res = await cmsApi<{ users: UserApiRow[] } | UserApiRow[]>('/users?pageSize=200')
156
- const list: UserApiRow[] = Array.isArray(res.data)
157
- ? res.data
158
- : ((res.data as { users?: UserApiRow[] } | null)?.users ?? [])
159
- const map = new Map<string, PostAuthor>()
160
- for (const u of list) {
161
- map.set(u.id, {
162
- id: u.id,
163
- name: u.name?.trim() || u.email,
164
- initials: initialsFromName(u.name, u.email),
165
- })
166
- }
167
- return map
168
- }
169
-
170
- // ─── Posts ───────────────────────────────────────────────────────────
171
-
172
- interface DocumentApiRow {
140
+ interface PostsApiPost {
173
141
  id: string
174
142
  collection: string
175
143
  title: string | null
176
144
  slug: string | null
177
145
  status: PostStatus
178
- createdById: string | null
179
- updatedById: string | null
180
146
  publishedAt: string | null
181
147
  scheduledAt: string | null
182
148
  createdAt: string
183
149
  updatedAt: string
184
- data: Record<string, unknown> | null
150
+ seoScore: number | null
151
+ author: { id: string; name: string } | null
185
152
  }
186
153
 
187
- function extractSeoScore(data: Record<string, unknown> | null): number | null {
188
- if (!data) return null
189
- const candidates = ['seoScore', 'seo_score', 'metaScore']
190
- for (const k of candidates) {
191
- const v = data[k]
192
- if (typeof v === 'number' && Number.isFinite(v)) return v
193
- }
194
- return null
154
+ interface PostsApiType {
155
+ slug: string
156
+ singular: string
157
+ plural: string
158
+ description: string | null
159
+ urlPrefix: string
160
+ color: string | null
161
+ icon: string | null
162
+ fieldCount: number
163
+ count: number
164
+ }
165
+
166
+ interface PostsApiResponse {
167
+ posts: PostsApiPost[]
168
+ total: number
169
+ page: number
170
+ pageSize: number
171
+ types: PostsApiType[]
172
+ authors: Array<{ id: string; name: string }>
195
173
  }
196
174
 
197
- function pickDisplayDate(d: DocumentApiRow): string {
175
+ /** Display date matches the old client behaviour: scheduled/published date when set, else updated. */
176
+ function pickDisplayDate(d: PostsApiPost): string {
198
177
  if (d.status === 'SCHEDULED' && d.scheduledAt) return d.scheduledAt
199
178
  if (d.status === 'PUBLISHED' && d.publishedAt) return d.publishedAt
200
179
  return d.updatedAt
201
180
  }
202
181
 
203
- interface ListEnvelope {
204
- docs: DocumentApiRow[]
205
- total: number
182
+ function buildPostsQueryString(query: PostsQuery): string {
183
+ const params = new URLSearchParams()
184
+ if (query.search?.trim()) params.set('search', query.search.trim())
185
+ if (query.postType && query.postType !== 'all') params.set('postType', query.postType)
186
+ if (query.status && query.status !== 'all') params.set('status', query.status)
187
+ if (query.author && query.author !== 'all') params.set('author', query.author)
188
+ if (query.sortBy) params.set('sortBy', query.sortBy)
189
+ if (query.sortDirection) params.set('sortDirection', query.sortDirection)
190
+ params.set('page', String(Math.max(1, query.page ?? 1)))
191
+ params.set('pageSize', String(Math.max(1, query.pageSize ?? 10)))
192
+ const qs = params.toString()
193
+ return qs ? `?${qs}` : ''
206
194
  }
207
195
 
208
196
  export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
209
- const cols = await fetchPostCollections()
210
- const authorsMap = await fetchAuthorsMap()
211
-
212
- // Pull every doc for each post type (cap at 500 to avoid runaway
213
- // requests; pagination + filtering still happens client-side over
214
- // this set). When a type grows past 500 docs we'll need the
215
- // server-side aggregate endpoint described in this file's header.
216
- const perTypeRaw = await Promise.all(
217
- cols.map(async (c) => {
218
- const res = await cmsApi<ListEnvelope>(
219
- `/collections/${encodeURIComponent(c.slug)}?pageSize=500&sort=updatedAt&order=desc`,
220
- )
221
- const docs = (res.data?.docs ?? []) as DocumentApiRow[]
222
- return { col: c, docs }
223
- }),
224
- )
225
-
226
- // Map docs → Post objects.
227
- const allPosts: Post[] = []
228
- for (const { col, docs } of perTypeRaw) {
229
- for (const d of docs) {
230
- const author = (d.createdById && authorsMap.get(d.createdById)) ||
231
- (d.updatedById && authorsMap.get(d.updatedById)) || {
232
- id: '',
233
- name: 'Unknown',
234
- initials: '??',
235
- }
236
- allPosts.push({
237
- id: d.id,
238
- title: d.title ?? (d.data?.title as string) ?? '(untitled)',
239
- slug: d.slug ?? (d.data?.slug as string) ?? '',
240
- postTypeId: col.slug,
241
- postTypeName: col.labels.singular,
242
- postTypeAccentColor: col.color ?? null,
243
- authorId: author.id,
244
- authorName: author.name,
245
- authorInitials: author.initials,
246
- status: d.status,
247
- publishDate: d.publishedAt ?? d.scheduledAt ?? null,
248
- updatedDate: pickDisplayDate(d),
249
- seoScore: extractSeoScore(d.data),
250
- createdAt: d.createdAt,
251
- updatedAt: d.updatedAt,
252
- })
253
- }
254
- }
197
+ const page = Math.max(1, query.page ?? 1)
198
+ const pageSize = Math.max(1, query.pageSize ?? 10)
255
199
 
256
- // Compute per-type counts BEFORE filtering — the tabs at the top
257
- // of the page reflect the dataset, not the active query.
258
- const countsBySlug = new Map<string, number>()
259
- for (const p of allPosts) {
260
- countsBySlug.set(p.postTypeId, (countsBySlug.get(p.postTypeId) ?? 0) + 1)
200
+ const res = await cmsApi<PostsApiResponse>(`/posts${buildPostsQueryString(query)}`)
201
+ if (res.error || !res.data) {
202
+ throw new Error(res.error ?? 'Failed to load posts')
261
203
  }
262
- const types: PostType[] = cols.map((c) => collectionToPostType(c, countsBySlug.get(c.slug) ?? 0))
263
-
264
- // Apply filters / search.
265
- const search = (query.search ?? '').trim().toLowerCase()
266
- const filtered = allPosts.filter((p) => {
267
- if (query.postType && query.postType !== 'all' && p.postTypeId !== query.postType) return false
268
- if (query.status && query.status !== 'all' && p.status !== query.status) return false
269
- if (query.author && query.author !== 'all' && p.authorId !== query.author) return false
270
- if (search) {
271
- const haystack = `${p.title} ${p.slug} ${p.authorName} ${p.postTypeName}`.toLowerCase()
272
- if (!haystack.includes(search)) return false
204
+ const r = res.data
205
+
206
+ const typeBySlug = new Map<string, PostsApiType>()
207
+ for (const t of r.types) typeBySlug.set(t.slug, t)
208
+
209
+ const types: PostType[] = r.types.map((t) => ({
210
+ id: t.slug,
211
+ slug: t.slug,
212
+ name: t.plural,
213
+ singularName: t.singular,
214
+ description: t.description ?? null,
215
+ urlBase: `/${t.urlPrefix || t.slug}`,
216
+ accentColor: t.color ?? null,
217
+ icon: t.icon ?? null,
218
+ postCount: t.count,
219
+ fieldCount: t.fieldCount,
220
+ active: true,
221
+ }))
222
+
223
+ const posts: Post[] = r.posts.map((d) => {
224
+ const type = typeBySlug.get(d.collection)
225
+ const authorName = d.author?.name?.trim() || 'Unknown'
226
+ return {
227
+ id: d.id,
228
+ title: d.title ?? '(untitled)',
229
+ slug: d.slug ?? '',
230
+ postTypeId: d.collection,
231
+ postTypeName: type?.singular ?? d.collection,
232
+ postTypeAccentColor: type?.color ?? null,
233
+ authorId: d.author?.id ?? '',
234
+ authorName,
235
+ authorInitials: d.author ? initialsFromName(d.author.name) : '??',
236
+ status: d.status,
237
+ publishDate: d.publishedAt ?? d.scheduledAt ?? null,
238
+ updatedDate: pickDisplayDate(d),
239
+ seoScore: d.seoScore,
240
+ createdAt: d.createdAt,
241
+ updatedAt: d.updatedAt,
273
242
  }
274
- return true
275
243
  })
276
244
 
277
- // Sort.
278
- const sortBy = query.sortBy ?? 'date'
279
- const dir = query.sortDirection ?? 'desc'
280
- filtered.sort((a, b) => {
281
- const cmp = comparePostsFor(sortBy, a, b)
282
- return dir === 'asc' ? cmp : -cmp
283
- })
284
-
285
- // Paginate.
286
- const page = Math.max(1, query.page ?? 1)
287
- const pageSize = Math.max(1, query.pageSize ?? 10)
288
- const start = (page - 1) * pageSize
289
- const paged = filtered.slice(start, start + pageSize)
290
-
291
- // Unique authors from the full set (so the dropdown lists every
292
- // contributor, not just the currently visible page).
293
- const authorIds = new Set<string>()
294
- const authors: PostAuthor[] = []
295
- for (const p of allPosts) {
296
- if (p.authorId && !authorIds.has(p.authorId)) {
297
- authorIds.add(p.authorId)
298
- authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials })
299
- }
300
- }
301
- authors.sort((a, b) => a.name.localeCompare(b.name))
245
+ const authors: PostAuthor[] = r.authors
246
+ .map((a) => ({
247
+ id: a.id,
248
+ name: a.name?.trim() || 'Unknown',
249
+ initials: initialsFromName(a.name),
250
+ }))
251
+ .sort((a, b) => a.name.localeCompare(b.name))
302
252
 
303
253
  return {
304
- posts: paged,
305
- total: filtered.length,
306
- page,
307
- pageSize,
254
+ posts,
255
+ total: r.total,
256
+ page: r.page ?? page,
257
+ pageSize: r.pageSize ?? pageSize,
308
258
  types,
309
259
  authors,
310
260
  }
311
261
  }
312
262
 
313
- function comparePostsFor(by: NonNullable<PostsQuery['sortBy']>, a: Post, b: Post): number {
314
- switch (by) {
315
- case 'title':
316
- return a.title.localeCompare(b.title)
317
- case 'type':
318
- return a.postTypeName.localeCompare(b.postTypeName)
319
- case 'author':
320
- return a.authorName.localeCompare(b.authorName)
321
- case 'status':
322
- return a.status.localeCompare(b.status)
323
- case 'seo': {
324
- const av = a.seoScore ?? -1
325
- const bv = b.seoScore ?? -1
326
- return av - bv
327
- }
328
- case 'date':
329
- default:
330
- return new Date(a.updatedDate).getTime() - new Date(b.updatedDate).getTime()
331
- }
332
- }
333
-
334
263
  // ─── Bulk mutations ──────────────────────────────────────────────────
335
264
 
336
265
  export async function bulkUpdatePosts(
@@ -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),
@@ -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