@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.
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- 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/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- 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/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.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/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.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/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.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/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- 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/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/index.ts +0 -2
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/views/Pages/PagesListView.tsx +113 -82
- 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/PagePreview.tsx +10 -1
- package/src/views/page-editor/PageSectionEditor.tsx +25 -1
- 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
|
@@ -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
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
108
|
+
}, [refreshToken])
|
|
112
109
|
|
|
113
|
-
// Reset paging/selection when the
|
|
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
|
|
120
|
-
const tags =
|
|
121
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
<
|
|
697
|
-
<
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
*
|
|
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),
|
|
@@ -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={() =>
|
|
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.
|
|
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
|
|
@@ -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
|
|
140
|
+
Featured image
|
|
138
141
|
</label>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
162
|
-
|
|
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
|
}
|