@prenta/admin 0.96.0 → 0.97.1
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/CHANGELOG.md +82 -0
- package/dist/__tests__/lib/page-editor-service.test.js +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +72 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +1 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/version-history-panel.render.test.js +23 -2
- package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +7 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +11 -1
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -1
- package/dist/views/page-editor/SectionEditForm.js +6 -1
- package/dist/views/page-editor/SectionEditForm.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +5 -1
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/sections/DividerSection.d.ts +14 -0
- package/dist/views/page-editor/sections/DividerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/DividerSection.js +19 -0
- package/dist/views/page-editor/sections/DividerSection.js.map +1 -0
- package/dist/views/page-editor/sections/EmbedSection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/EmbedSection.js +11 -3
- package/dist/views/page-editor/sections/EmbedSection.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +4 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/WidgetEmbedSection.js +1 -1
- package/dist/views/page-editor/sections/WidgetEmbedSection.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/lib/page-editor-service.test.ts +1 -0
- package/src/__tests__/lib/post-editor-service.test.ts +77 -0
- package/src/__tests__/views/post-section-editor.test.tsx +1 -0
- package/src/__tests__/views/version-history-panel.render.test.tsx +26 -2
- package/src/lib/post-editor-service.ts +7 -0
- package/src/views/Posts/PostsListView.tsx +17 -1
- package/src/views/page-editor/SectionEditForm.tsx +9 -1
- package/src/views/page-editor/SectionInspector.tsx +7 -2
- package/src/views/page-editor/sections/DividerSection.tsx +31 -0
- package/src/views/page-editor/sections/EmbedSection.tsx +11 -5
- package/src/views/page-editor/sections/SectionRenderer.tsx +4 -0
- package/src/views/page-editor/sections/WidgetEmbedSection.tsx +4 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
4
|
import { VersionHistoryPanel } from '../../views/page-editor/VersionHistoryPanel.js'
|
|
5
5
|
import type { PageVersion } from '../../lib/version-history.js'
|
|
@@ -11,7 +11,31 @@ function section(id: string, content: Record<string, unknown>): PageSection {
|
|
|
11
11
|
return { id, sectionType: 'hero', name: 'Hero Banner', visible: true, content, settings: {} }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A FIXED "now", at midday, because these tests assert on the words "Today"
|
|
16
|
+
* and "Yesterday".
|
|
17
|
+
*
|
|
18
|
+
* `new Date()` made them depend on what time of day CI happened to run. The
|
|
19
|
+
* fixtures sit 1-30 hours back, so a run shortly after UTC midnight put every
|
|
20
|
+
* one of them in the previous day and the panel correctly rendered
|
|
21
|
+
* "Yesterday" where the test demanded "Today". It failed at 00:13 UTC having
|
|
22
|
+
* passed all day — the suite was green on any machine whose clock was far
|
|
23
|
+
* enough from midnight, which is most of them, most of the time.
|
|
24
|
+
*
|
|
25
|
+
* Midday leaves ~12 hours of headroom either side of the day boundary, so
|
|
26
|
+
* every offset below lands on the day its assertion names.
|
|
27
|
+
*/
|
|
28
|
+
const NOW = new Date('2026-06-15T12:00:00.000Z')
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.useFakeTimers()
|
|
32
|
+
vi.setSystemTime(NOW)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.useRealTimers()
|
|
37
|
+
})
|
|
38
|
+
|
|
15
39
|
const iso = (hoursAgo: number) => new Date(NOW.getTime() - hoursAgo * 3_600_000).toISOString()
|
|
16
40
|
|
|
17
41
|
const VERSIONS: PageVersion[] = [
|
|
@@ -111,6 +111,12 @@ export interface ValidationResult {
|
|
|
111
111
|
* key not declared as a field on the collection, so a post-type collection
|
|
112
112
|
* missing e.g. `sections` would discard all section content on save with no
|
|
113
113
|
* error. Mirrors the Page Editor's `missingPageFields` guard.
|
|
114
|
+
*
|
|
115
|
+
* Keep this list in sync with the keys `toWriteBody` sends: a key that is
|
|
116
|
+
* written but not guarded is exactly the silent data loss this guard exists
|
|
117
|
+
* to prevent. `title`/`slug` are omitted deliberately — they're required by
|
|
118
|
+
* every collection shape, and a post type without them cannot be listed in
|
|
119
|
+
* the first place.
|
|
114
120
|
*/
|
|
115
121
|
const REQUIRED_POST_FIELDS = [
|
|
116
122
|
'sections',
|
|
@@ -118,6 +124,7 @@ const REQUIRED_POST_FIELDS = [
|
|
|
118
124
|
'featuredImage',
|
|
119
125
|
'body',
|
|
120
126
|
'category',
|
|
127
|
+
'tags',
|
|
121
128
|
'publishDate',
|
|
122
129
|
'metaTitle',
|
|
123
130
|
'metaDescription',
|
|
@@ -60,6 +60,17 @@ type Dir = 'asc' | 'desc'
|
|
|
60
60
|
|
|
61
61
|
const PAGE_SIZE = 10
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The table is `table-fixed`, so the flexible Title column only gets whatever
|
|
65
|
+
* the min-width leaves after the seven fixed columns (810px). The previous
|
|
66
|
+
* 760px was *below* that sum, so at ≤810px of table width the Title column
|
|
67
|
+
* collapsed to zero and the post title vanished entirely — the row kept its
|
|
68
|
+
* checkbox, type, author, status, date, SEO and actions, and showed no title
|
|
69
|
+
* at all. Keep ≥240px for the title and let the table scroll sideways
|
|
70
|
+
* instead. Mirrors `tableMinWidth` in the Pages list, which hit this first.
|
|
71
|
+
*/
|
|
72
|
+
const TABLE_MIN_WIDTH = 810 + 240
|
|
73
|
+
|
|
63
74
|
export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
64
75
|
const [activeType, setActiveType] = useState<string>('all')
|
|
65
76
|
const [search, setSearch] = useState('')
|
|
@@ -331,7 +342,12 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
331
342
|
{/* Both-axis scroll: tall lists scroll vertically; narrow viewports
|
|
332
343
|
keep column widths and scroll sideways instead of crushing. */}
|
|
333
344
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
334
|
-
<table
|
|
345
|
+
<table
|
|
346
|
+
className="w-full table-fixed"
|
|
347
|
+
style={{ minWidth: TABLE_MIN_WIDTH }}
|
|
348
|
+
role="table"
|
|
349
|
+
aria-label="Posts"
|
|
350
|
+
>
|
|
335
351
|
<colgroup>
|
|
336
352
|
<col style={{ width: '40px' }} />
|
|
337
353
|
<col />
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { ExternalLink, Lock } from 'lucide-react'
|
|
4
4
|
import { RichTextField } from '../../fields/RichTextField.js'
|
|
5
5
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
6
|
+
import { visibleFields } from '@prenta/core/page-sections'
|
|
6
7
|
import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
7
8
|
import { getSectionType } from './section-types.js'
|
|
8
9
|
|
|
@@ -75,7 +76,14 @@ export function SectionEditForm({
|
|
|
75
76
|
section.sectionType === 'article-body' &&
|
|
76
77
|
bodySource === 'field' &&
|
|
77
78
|
onPostBodyChange !== undefined
|
|
78
|
-
|
|
79
|
+
// `visibleFields` first, so a type offering alternative inputs for one job
|
|
80
|
+
// (see the video section's source switch) shows only the box that is live.
|
|
81
|
+
// The validator reads the same helper, so it can never require a field the
|
|
82
|
+
// editor is not drawing.
|
|
83
|
+
const contentFields =
|
|
84
|
+
visibleFields(def?.fields ?? [], section.content).filter(
|
|
85
|
+
(f) => !(editPostBodyField && f.key === 'body'),
|
|
86
|
+
) ?? []
|
|
79
87
|
|
|
80
88
|
return (
|
|
81
89
|
<div className="border-border bg-muted/40 border-t px-3 py-3.5">
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect } from 'react'
|
|
4
4
|
import { Eye, EyeOff, Lock, X } from 'lucide-react'
|
|
5
5
|
import { InspectorExpandButton, ResizableAside } from '../../components/ui/ResizableAside.js'
|
|
6
|
+
import { visibleFields } from '@prenta/core/page-sections'
|
|
6
7
|
import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
7
8
|
import { RichTextField } from '../../fields/RichTextField.js'
|
|
8
9
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
@@ -66,8 +67,12 @@ export function SectionInspector({
|
|
|
66
67
|
section.sectionType === 'article-body' &&
|
|
67
68
|
bodySource === 'field' &&
|
|
68
69
|
onPostBodyChange !== undefined
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// Same visibility rule as the inline form and the validator — see
|
|
71
|
+
// `visibleFields`. The template inspector must not offer a box the page
|
|
72
|
+
// editor hides, or the two surfaces disagree about the same section.
|
|
73
|
+
const contentFields = visibleFields(def?.fields ?? [], section.content).filter(
|
|
74
|
+
(field) => !(editPostBodyField && field.key === 'body'),
|
|
75
|
+
)
|
|
71
76
|
|
|
72
77
|
// Escape closes the inspector.
|
|
73
78
|
useEffect(() => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PageSection } from '../../../lib/page-editor-service.js'
|
|
2
|
+
import { str } from './parts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canvas body for the `divider` section.
|
|
6
|
+
*
|
|
7
|
+
* `space` deliberately renders an empty box rather than nothing: on the public
|
|
8
|
+
* page the section wrapper's padding IS the break, but on the canvas a section
|
|
9
|
+
* that draws nothing at all reads as broken. A faint dashed outline says "this
|
|
10
|
+
* is intentional whitespace" without pretending to be what ships — the canvas
|
|
11
|
+
* shows structure, and empty space is the structure here.
|
|
12
|
+
*/
|
|
13
|
+
export function DividerSection({ section }: { section: PageSection }) {
|
|
14
|
+
const variant = str(section.content, 'variant') || 'line'
|
|
15
|
+
|
|
16
|
+
if (variant === 'space') {
|
|
17
|
+
return (
|
|
18
|
+
<div className="mx-auto max-w-3xl px-6">
|
|
19
|
+
<div className="border-border text-muted-foreground flex h-10 items-center justify-center rounded-md border border-dashed text-xs">
|
|
20
|
+
Space
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="mx-auto max-w-3xl px-6">
|
|
28
|
+
<hr className="border-0 border-t border-current opacity-15" aria-hidden />
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PlayCircle } from 'lucide-react'
|
|
2
|
-
import {
|
|
2
|
+
import { resolveVideo, EMBED_PROVIDER_LABEL } from '@prenta/core/page-sections'
|
|
3
3
|
import type { PageSection } from '../../../lib/page-editor-service.js'
|
|
4
4
|
import { str } from './parts.js'
|
|
5
5
|
|
|
@@ -17,10 +17,18 @@ const RATIO_CLASS: Record<string, string> = {
|
|
|
17
17
|
*/
|
|
18
18
|
export function EmbedSection({ section }: { section: PageSection }) {
|
|
19
19
|
const c = section.content
|
|
20
|
-
const
|
|
20
|
+
const video = resolveVideo(c)
|
|
21
21
|
const ratioClass = RATIO_CLASS[str(c, 'ratio', '16:9')] ?? RATIO_CLASS['16:9']
|
|
22
22
|
const title = str(c, 'title')
|
|
23
23
|
const caption = str(c, 'caption')
|
|
24
|
+
// Naming the resolved source is the only confirmation an editor gets that
|
|
25
|
+
// the link they pasted, or the file they picked, actually resolved — the
|
|
26
|
+
// canvas never loads the real video either way.
|
|
27
|
+
const label = !video
|
|
28
|
+
? 'No valid video set'
|
|
29
|
+
: video.kind === 'hosted'
|
|
30
|
+
? 'Uploaded video'
|
|
31
|
+
: `${EMBED_PROVIDER_LABEL[video.provider]} video`
|
|
24
32
|
|
|
25
33
|
return (
|
|
26
34
|
<figure className="mx-auto max-w-3xl px-6">
|
|
@@ -28,9 +36,7 @@ export function EmbedSection({ section }: { section: PageSection }) {
|
|
|
28
36
|
className={`bg-muted text-muted-foreground relative flex w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-xl ${ratioClass}`}
|
|
29
37
|
>
|
|
30
38
|
<PlayCircle className="h-10 w-10" aria-hidden />
|
|
31
|
-
<p className="text-foreground text-sm font-medium">
|
|
32
|
-
{parsed ? `${EMBED_PROVIDER_LABEL[parsed.provider]} video` : 'No valid video URL'}
|
|
33
|
-
</p>
|
|
39
|
+
<p className="text-foreground text-sm font-medium">{label}</p>
|
|
34
40
|
{title && <p className="text-muted-foreground px-4 text-center text-xs">{title}</p>}
|
|
35
41
|
</div>
|
|
36
42
|
{caption && (
|
|
@@ -18,6 +18,7 @@ import { RelatedPostsSection } from './RelatedPostsSection.js'
|
|
|
18
18
|
import { GallerySection } from './GallerySection.js'
|
|
19
19
|
import { EmbedSection } from './EmbedSection.js'
|
|
20
20
|
import { WidgetEmbedSection } from './WidgetEmbedSection.js'
|
|
21
|
+
import { DividerSection } from './DividerSection.js'
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Section types with a real canvas body component below. Anything else —
|
|
@@ -38,6 +39,7 @@ const CANVAS_RENDERED_TYPES: ReadonlySet<string> = new Set([
|
|
|
38
39
|
'gallery',
|
|
39
40
|
'embed',
|
|
40
41
|
'widget-embed',
|
|
42
|
+
'divider',
|
|
41
43
|
])
|
|
42
44
|
|
|
43
45
|
/** True when the canvas renders this type for real (not as a placeholder). */
|
|
@@ -126,6 +128,8 @@ function BuiltInSectionBody({
|
|
|
126
128
|
return <EmbedSection section={section} />
|
|
127
129
|
case 'widget-embed':
|
|
128
130
|
return <WidgetEmbedSection section={section} />
|
|
131
|
+
case 'divider':
|
|
132
|
+
return <DividerSection section={section} />
|
|
129
133
|
default:
|
|
130
134
|
// Config-declared custom types and unmanaged sections with no supplied
|
|
131
135
|
// renderer: a neutral placeholder keeps the canvas reflecting the
|
|
@@ -20,10 +20,13 @@ export function WidgetEmbedSection({ section }: { section: PageSection }) {
|
|
|
20
20
|
style={{ minHeight: `${minHeight}px` }}
|
|
21
21
|
>
|
|
22
22
|
<Puzzle className="h-8 w-8" aria-hidden />
|
|
23
|
+
{/* Naming the recognised provider is the only feedback an editor gets
|
|
24
|
+
that their pasted snippet parsed — the widget itself never loads in
|
|
25
|
+
the canvas, so "it looks blank" is the expected state either way. */}
|
|
23
26
|
<p className="text-foreground text-sm font-medium">
|
|
24
27
|
{parsed
|
|
25
28
|
? `${WIDGET_PROVIDER_LABEL[parsed.provider]} widget`
|
|
26
|
-
: '
|
|
29
|
+
: 'Paste embed code to load a widget'}
|
|
27
30
|
</p>
|
|
28
31
|
{title && <p className="text-muted-foreground px-4 text-center text-xs">{title}</p>}
|
|
29
32
|
</div>
|