@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.
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/index.ts +0 -2
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
updateSectionMeta,
|
|
35
35
|
coerceSections,
|
|
36
36
|
coercePostHeader,
|
|
37
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
:
|
|
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
|
|
236
|
+
const r = res.data
|
|
236
237
|
return {
|
|
237
|
-
docId:
|
|
238
|
+
docId: r.docId ?? null,
|
|
238
239
|
postType: template.postType,
|
|
239
|
-
header: coercePostHeader(
|
|
240
|
-
sections: coerceSections(
|
|
240
|
+
header: coercePostHeader(r.header),
|
|
241
|
+
sections: coerceSections(r.sections),
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
|
package/src/lib/posts-service.ts
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Posts service — thin client over the
|
|
3
|
-
*
|
|
2
|
+
* Posts service — thin client over the CMS REST endpoints, shaped around the
|
|
3
|
+
* data the Posts admin area needs.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
// ───
|
|
127
|
+
// ─── Authors ─────────────────────────────────────────────────────────
|
|
137
128
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
seoScore: number | null
|
|
151
|
+
author: { id: string; name: string } | null
|
|
185
152
|
}
|
|
186
153
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
210
|
-
const
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
305
|
-
total:
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* a chooser
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* adding a new collection
|
|
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.
|
|
8
|
-
*
|
|
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
|
|
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
|
-
{
|
|
41
|
-
<div
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|