@actuate-media/cms-admin 0.17.0 → 0.18.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__/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/actuate-admin.css +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/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/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/package.json +1 -1
- 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__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/page-editor/PagePreview.tsx +10 -1
- package/src/views/page-editor/PageSectionEditor.tsx +25 -1
|
@@ -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
|
|
|
@@ -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(() => {
|