@prenta/admin 0.94.0 → 0.95.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/CHANGELOG.md +41 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
- package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
- package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
- package/dist/__tests__/views/section-inspector-media.test.js +1 -1
- package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +16 -10
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
- package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
- package/dist/views/page-editor/SectionEditForm.js +48 -0
- package/dist/views/page-editor/SectionEditForm.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +0 -20
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +5 -223
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +11 -5
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/section-fields.d.ts +43 -0
- package/dist/views/page-editor/section-fields.d.ts.map +1 -0
- package/dist/views/page-editor/section-fields.js +236 -0
- package/dist/views/page-editor/section-fields.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +11 -9
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
- package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
- package/src/__tests__/views/section-inspector-media.test.ts +1 -1
- package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
- package/src/components/CanvasEditTip.tsx +3 -3
- package/src/views/page-editor/PageSectionEditor.tsx +22 -24
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +7 -552
- package/src/views/page-editor/SectionsPanel.tsx +70 -14
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +18 -26
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import { SectionEditForm } from '../../views/page-editor/SectionEditForm.js'
|
|
5
|
+
import { SectionsList } from '../../views/page-editor/SectionsPanel.js'
|
|
6
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
function section(over: Partial<PageSection> = {}): PageSection {
|
|
11
|
+
return {
|
|
12
|
+
id: 's1',
|
|
13
|
+
sectionType: 'hero',
|
|
14
|
+
name: 'Hero Banner',
|
|
15
|
+
visible: true,
|
|
16
|
+
content: { heading: 'Existing headline' },
|
|
17
|
+
settings: { backgroundType: 'solid', backgroundColor: 'purple', paddingY: 'lg' },
|
|
18
|
+
...over,
|
|
19
|
+
} as PageSection
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderForm(over: Partial<Parameters<typeof SectionEditForm>[0]> = {}) {
|
|
23
|
+
const onContentChange = vi.fn()
|
|
24
|
+
const onMetaChange = vi.fn()
|
|
25
|
+
render(
|
|
26
|
+
<SectionEditForm
|
|
27
|
+
section={section()}
|
|
28
|
+
canEdit
|
|
29
|
+
onContentChange={onContentChange}
|
|
30
|
+
onMetaChange={onMetaChange}
|
|
31
|
+
{...over}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
return { onContentChange, onMetaChange }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('SectionEditForm — content only', () => {
|
|
38
|
+
it('renders the type’s content fields', () => {
|
|
39
|
+
renderForm()
|
|
40
|
+
expect(screen.getByDisplayValue('Existing headline')).toBeTruthy()
|
|
41
|
+
// Exact: the hero type also declares "Primary button URL", and a loose
|
|
42
|
+
// match would find both and fail for the wrong reason.
|
|
43
|
+
expect(screen.getByLabelText('Primary button')).toBeTruthy()
|
|
44
|
+
expect(screen.getByLabelText('Primary button URL')).toBeTruthy()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('edits content through the patch sink', () => {
|
|
48
|
+
const { onContentChange } = renderForm()
|
|
49
|
+
fireEvent.change(screen.getByDisplayValue('Existing headline'), {
|
|
50
|
+
target: { value: 'New headline' },
|
|
51
|
+
})
|
|
52
|
+
expect(onContentChange).toHaveBeenCalledWith({ heading: 'New headline' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The reason this component exists rather than a trimmed inspector. This CMS
|
|
57
|
+
* edits words, images and embed code; `docs/design-owned-sections.md` says
|
|
58
|
+
* the editor never changes styling, and the old panel offered background,
|
|
59
|
+
* colour, spacing and a CSS class one panel away from that sentence.
|
|
60
|
+
*/
|
|
61
|
+
it('offers no styling controls at all', () => {
|
|
62
|
+
renderForm()
|
|
63
|
+
for (const label of [/^Background$/i, /^Color$/i, /^Spacing$/i, /CSS class/i]) {
|
|
64
|
+
expect(screen.queryByLabelText(label), `${label} is still editable`).toBeNull()
|
|
65
|
+
}
|
|
66
|
+
expect(screen.queryByText(/Appearance/i)).toBeNull()
|
|
67
|
+
expect(screen.queryByRole('combobox')).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('says where the missing controls went, so their absence reads as deliberate', () => {
|
|
71
|
+
renderForm()
|
|
72
|
+
expect(screen.getByText(/come from the template/i)).toBeTruthy()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps the naming fields, which are not styling', () => {
|
|
76
|
+
const { onMetaChange } = renderForm()
|
|
77
|
+
fireEvent.change(screen.getByLabelText('Section name'), { target: { value: 'Intro' } })
|
|
78
|
+
expect(onMetaChange).toHaveBeenCalledWith({ name: 'Intro' })
|
|
79
|
+
expect(screen.getByLabelText('Internal label')).toBeTruthy()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('explains an unmanaged section instead of rendering an empty form', () => {
|
|
83
|
+
renderForm({ section: section({ sectionType: 'opal-pricing', unmanaged: true }) })
|
|
84
|
+
expect(screen.getByText(/Managed outside the editor/i)).toBeTruthy()
|
|
85
|
+
expect(screen.queryByLabelText('Section name')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('disables every control for a read-only user', () => {
|
|
89
|
+
renderForm({ canEdit: false })
|
|
90
|
+
// Asserted on the fieldset, not the input: a disabled ancestor fieldset is
|
|
91
|
+
// what actually disables the controls, and `input.disabled` reports only
|
|
92
|
+
// the element's own attribute, so checking the input passes either way.
|
|
93
|
+
const field = screen.getByDisplayValue('Existing headline')
|
|
94
|
+
const fieldset = field.closest('fieldset')
|
|
95
|
+
expect(fieldset).toBeTruthy()
|
|
96
|
+
expect(fieldset!.disabled).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('SectionsList — the editor opens inside its row', () => {
|
|
101
|
+
const sections = [section(), section({ id: 's2', name: 'CTA Banner', sectionType: 'cta' })]
|
|
102
|
+
|
|
103
|
+
function renderList(selectedId: string | null, withEditor = true) {
|
|
104
|
+
const onSelect = vi.fn()
|
|
105
|
+
render(
|
|
106
|
+
<SectionsList
|
|
107
|
+
sections={sections}
|
|
108
|
+
selectedId={selectedId}
|
|
109
|
+
canEdit
|
|
110
|
+
onSelect={onSelect}
|
|
111
|
+
onToggleVisibility={vi.fn()}
|
|
112
|
+
renderEditor={
|
|
113
|
+
withEditor
|
|
114
|
+
? (s) => <div data-testid={`editor-${s.id}`}>editor for {s.name}</div>
|
|
115
|
+
: undefined
|
|
116
|
+
}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
return { onSelect }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it('renders the editor for the selected row and no other', () => {
|
|
123
|
+
renderList('s1')
|
|
124
|
+
expect(screen.getByTestId('editor-s1')).toBeTruthy()
|
|
125
|
+
expect(screen.queryByTestId('editor-s2')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders no editor when nothing is selected', () => {
|
|
129
|
+
renderList(null)
|
|
130
|
+
expect(screen.queryByTestId('editor-s1')).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('gives the row a disclosure control that reports its state', () => {
|
|
134
|
+
renderList('s1')
|
|
135
|
+
// The chevron is what says the row opens at all — without one the list
|
|
136
|
+
// reads as links to somewhere else, which is what it was.
|
|
137
|
+
const open = screen.getByRole('button', { name: 'Close Hero Banner' })
|
|
138
|
+
expect(open.getAttribute('aria-expanded')).toBe('true')
|
|
139
|
+
expect(
|
|
140
|
+
screen.getByRole('button', { name: 'Edit CTA Banner' }).getAttribute('aria-expanded'),
|
|
141
|
+
).toBe('false')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('points the disclosure at the editor it opens', () => {
|
|
145
|
+
renderList('s1')
|
|
146
|
+
const control = screen.getByRole('button', { name: 'Close Hero Banner' })
|
|
147
|
+
const target = control.getAttribute('aria-controls')
|
|
148
|
+
expect(target).toBe('section-editor-s1')
|
|
149
|
+
expect(document.getElementById(target!)).toBeTruthy()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('selects a closed row when its chevron is used', () => {
|
|
153
|
+
const { onSelect } = renderList('s1')
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit CTA Banner' }))
|
|
155
|
+
expect(onSelect).toHaveBeenCalledWith('s2')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('shows no disclosure when the list renders no editor (template list)', () => {
|
|
159
|
+
renderList('s1', false)
|
|
160
|
+
expect(screen.queryByRole('button', { name: /^(Edit|Close) / })).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { companionDimensionKeys } from '../../views/page-editor/
|
|
2
|
+
import { companionDimensionKeys } from '../../views/page-editor/section-fields.js'
|
|
3
3
|
|
|
4
4
|
describe('companionDimensionKeys', () => {
|
|
5
5
|
it('maps a *Url field to *Width / *Height (feature, hero)', () => {
|
|
@@ -3,7 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { render, screen } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
import { createSection } from '../../lib/page-editor-service.js'
|
|
6
|
-
import { SectionInspector
|
|
6
|
+
import { SectionInspector } from '../../views/page-editor/SectionInspector.js'
|
|
7
|
+
import { repeaterRowSummary } from '../../views/page-editor/section-fields.js'
|
|
7
8
|
import type { SectionFieldDef } from '../../views/page-editor/section-types.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -26,9 +26,9 @@ export function CanvasEditTip() {
|
|
|
26
26
|
<p className="min-w-0 flex-1 leading-[1.5]">
|
|
27
27
|
<span className="text-foreground font-medium">Editing tip</span>
|
|
28
28
|
{' — '}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Click a section on the canvas, or in the list, to open its copy and images in the section
|
|
30
|
+
list. Use <span className="text-foreground font-medium">Site preview</span> to click text
|
|
31
|
+
and images in place.
|
|
32
32
|
</p>
|
|
33
33
|
<button
|
|
34
34
|
type="button"
|
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
updateSectionContent,
|
|
30
30
|
updateSectionContentAtPath,
|
|
31
31
|
updateSectionMeta,
|
|
32
|
-
updateSectionSettings,
|
|
33
32
|
validatePage,
|
|
34
33
|
type EditorPage,
|
|
35
34
|
type ValidationResult,
|
|
@@ -41,7 +40,6 @@ import {
|
|
|
41
40
|
createPreviewToken,
|
|
42
41
|
} from '../../lib/preview-link.js'
|
|
43
42
|
import { usePublicSiteUrl } from '../../hooks/usePublicSiteUrl.js'
|
|
44
|
-
import type { SectionSettings } from './section-types.js'
|
|
45
43
|
import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
|
|
46
44
|
import type { Zoom } from '../../lib/breakpoints.js'
|
|
47
45
|
import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
|
|
@@ -63,7 +61,7 @@ import { EditorCanvas } from './EditorCanvas.js'
|
|
|
63
61
|
import { ReflowContext } from './reflow-context.js'
|
|
64
62
|
import { SitePreviewFrame } from './SitePreviewFrame.js'
|
|
65
63
|
import { useSitePreview } from './useSitePreview.js'
|
|
66
|
-
import {
|
|
64
|
+
import { SectionEditForm } from './SectionEditForm.js'
|
|
67
65
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
68
66
|
import { ValidationSummary } from './ValidationSummary.js'
|
|
69
67
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
@@ -508,13 +506,12 @@ export function PageSectionEditor({
|
|
|
508
506
|
[selectedId, mutateSections],
|
|
509
507
|
)
|
|
510
508
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
)
|
|
509
|
+
// NOTE: there is deliberately no `handleSettingsChange` here any more.
|
|
510
|
+
// `settings` holds background, spacing, anchor and CSS class — the template's
|
|
511
|
+
// business, not the editor's (docs/design-owned-sections.md). The values
|
|
512
|
+
// still round-trip through save untouched and the renderers still read them;
|
|
513
|
+
// this editor simply no longer offers to change them. The post TEMPLATE
|
|
514
|
+
// editor keeps the full set, because that is the surface that authors one.
|
|
518
515
|
|
|
519
516
|
const handleMetaChange = useCallback(
|
|
520
517
|
(patch: { name?: string; internalLabel?: string }) => {
|
|
@@ -902,8 +899,6 @@ export function PageSectionEditor({
|
|
|
902
899
|
)
|
|
903
900
|
}
|
|
904
901
|
|
|
905
|
-
const selected = page.sections.find((s) => s.id === selectedId) ?? null
|
|
906
|
-
|
|
907
902
|
// `readOnly` / `effectiveCanEdit` / `canRestoreVersion` are computed above,
|
|
908
903
|
// with the hooks, so the callbacks defined there share this one gate.
|
|
909
904
|
|
|
@@ -1049,6 +1044,21 @@ export function PageSectionEditor({
|
|
|
1049
1044
|
canEdit={effectiveCanEdit}
|
|
1050
1045
|
onSelect={handleSelectSection}
|
|
1051
1046
|
onToggleVisibility={handleToggleVisibility}
|
|
1047
|
+
// The editor opens inside the row it belongs to. While
|
|
1048
|
+
// reviewing a past version there is nothing to edit, so
|
|
1049
|
+
// rows stay closed rather than opening a dead form.
|
|
1050
|
+
renderEditor={
|
|
1051
|
+
reviewing
|
|
1052
|
+
? undefined
|
|
1053
|
+
: (section) => (
|
|
1054
|
+
<SectionEditForm
|
|
1055
|
+
section={section}
|
|
1056
|
+
canEdit={effectiveCanEdit}
|
|
1057
|
+
onContentChange={handleContentChange}
|
|
1058
|
+
onMetaChange={handleMetaChange}
|
|
1059
|
+
/>
|
|
1060
|
+
)
|
|
1061
|
+
}
|
|
1052
1062
|
/>
|
|
1053
1063
|
) : railTab === 'page' ? (
|
|
1054
1064
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
@@ -1153,18 +1163,6 @@ export function PageSectionEditor({
|
|
|
1153
1163
|
/>
|
|
1154
1164
|
)}
|
|
1155
1165
|
</div>
|
|
1156
|
-
|
|
1157
|
-
{selected && railTab === 'sections' && !reviewing && (
|
|
1158
|
-
<SectionInspector
|
|
1159
|
-
section={selected}
|
|
1160
|
-
canEdit={effectiveCanEdit}
|
|
1161
|
-
onClose={() => setSelectedId(null)}
|
|
1162
|
-
onContentChange={handleContentChange}
|
|
1163
|
-
onSettingsChange={handleSettingsChange}
|
|
1164
|
-
onMetaChange={handleMetaChange}
|
|
1165
|
-
onToggleVisibility={() => handleToggleVisibility(selected.id)}
|
|
1166
|
-
/>
|
|
1167
|
-
)}
|
|
1168
1166
|
</div>
|
|
1169
1167
|
</ReflowContext.Provider>
|
|
1170
1168
|
</div>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Lock } from 'lucide-react'
|
|
4
|
+
import { RichTextField } from '../../fields/RichTextField.js'
|
|
5
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
6
|
+
import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
7
|
+
import { getSectionType } from './section-types.js'
|
|
8
|
+
|
|
9
|
+
export interface SectionEditFormProps {
|
|
10
|
+
section: PageSection
|
|
11
|
+
canEdit: boolean
|
|
12
|
+
onContentChange: (patch: Record<string, unknown>) => void
|
|
13
|
+
onMetaChange: (patch: { name?: string; internalLabel?: string }) => void
|
|
14
|
+
/** Post body field — shown when an Article Body section is bound to the post field. */
|
|
15
|
+
postBody?: string
|
|
16
|
+
onPostBodyChange?: (html: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The section editor, rendered inline as the body of its row.
|
|
21
|
+
*
|
|
22
|
+
* ── Why inline, and not a docked panel ────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The design puts the form inside the row it belongs to: click a section, it
|
|
25
|
+
* opens underneath. It never had a right-hand panel. The panel that existed
|
|
26
|
+
* here was left over from the pre-handoff editor, and the rail work that
|
|
27
|
+
* introduced the tab strip explicitly retired the OTHER right-hand aside — the
|
|
28
|
+
* SEO one — on the grounds that "the editor has one settings surface rather
|
|
29
|
+
* than two competing ones". This is that same second surface, finally closed.
|
|
30
|
+
*
|
|
31
|
+
* ── Why there is no Appearance group ──────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* This CMS edits words, images and embed code. Background, colour and spacing
|
|
34
|
+
* are the template's business, and `docs/design-owned-sections.md` already
|
|
35
|
+
* says so in as many words: the editor changes "copy, CTAs, and imagery —
|
|
36
|
+
* never layout, order, or styling". The controls contradicted that rule while
|
|
37
|
+
* sitting one panel away from the sentence stating it.
|
|
38
|
+
*
|
|
39
|
+
* The VALUES are untouched. `settings` round-trips through save exactly as
|
|
40
|
+
* before, the renderers keep reading it, and every existing page renders
|
|
41
|
+
* identically — the editor simply stops offering to change it. Removing the
|
|
42
|
+
* controls and dropping the data are very different changes, and this is only
|
|
43
|
+
* the first. The template editor keeps the full set: it is the surface that
|
|
44
|
+
* authors a template, and styling genuinely belongs there.
|
|
45
|
+
*/
|
|
46
|
+
export function SectionEditForm({
|
|
47
|
+
section,
|
|
48
|
+
canEdit,
|
|
49
|
+
onContentChange,
|
|
50
|
+
onMetaChange,
|
|
51
|
+
postBody,
|
|
52
|
+
onPostBodyChange,
|
|
53
|
+
}: SectionEditFormProps) {
|
|
54
|
+
const def = getSectionType(section.sectionType)
|
|
55
|
+
|
|
56
|
+
// Unmanaged types have no registered field schema, so there is nothing to
|
|
57
|
+
// render a form from. Say why rather than showing an empty box.
|
|
58
|
+
if (section.unmanaged) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3">
|
|
61
|
+
<p className="text-muted-foreground flex items-start gap-2 text-xs leading-relaxed">
|
|
62
|
+
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
63
|
+
<span>
|
|
64
|
+
<span className="text-foreground font-medium">Managed outside the editor</span> — this
|
|
65
|
+
section’s type (<code className="font-mono">{section.sectionType}</code>) is not
|
|
66
|
+
registered, so it is preserved on save but edited in code.
|
|
67
|
+
</span>
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bodySource = String(section.content.source ?? 'field')
|
|
74
|
+
const editPostBodyField =
|
|
75
|
+
section.sectionType === 'article-body' &&
|
|
76
|
+
bodySource === 'field' &&
|
|
77
|
+
onPostBodyChange !== undefined
|
|
78
|
+
const contentFields = def?.fields.filter((f) => !(editPostBodyField && f.key === 'body')) ?? []
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3.5">
|
|
82
|
+
<fieldset disabled={!canEdit} className="space-y-3">
|
|
83
|
+
{editPostBodyField && (
|
|
84
|
+
<RichTextField
|
|
85
|
+
label="Body"
|
|
86
|
+
value={postBody ?? ''}
|
|
87
|
+
onChange={onPostBodyChange}
|
|
88
|
+
className="[&_.ProseMirror]:min-h-[240px]"
|
|
89
|
+
helpText="Main article copy — rendered in the canvas preview and on the live site."
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{contentFields.map((field) => (
|
|
94
|
+
<ContentField
|
|
95
|
+
key={field.key}
|
|
96
|
+
id={`sec-${section.id}-${field.key}`}
|
|
97
|
+
field={field}
|
|
98
|
+
value={section.content[field.key]}
|
|
99
|
+
onChange={(v) => onContentChange({ [field.key]: v })}
|
|
100
|
+
onPatch={(patch) => onContentChange(patch)}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
{contentFields.length === 0 && !editPostBodyField && (
|
|
105
|
+
<p className="text-muted-foreground text-xs">
|
|
106
|
+
This section has no editable content — it is rendered entirely from the template.
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<details className="group">
|
|
111
|
+
<summary className="text-muted-foreground hover:text-foreground cursor-pointer list-none text-[11px] font-medium tracking-wider uppercase">
|
|
112
|
+
Section settings
|
|
113
|
+
</summary>
|
|
114
|
+
<div className="mt-2.5 space-y-3">
|
|
115
|
+
<div>
|
|
116
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-name`}>
|
|
117
|
+
Section name
|
|
118
|
+
</label>
|
|
119
|
+
<input
|
|
120
|
+
id={`sec-${section.id}-name`}
|
|
121
|
+
className={FIELD_INPUT}
|
|
122
|
+
value={section.name}
|
|
123
|
+
onChange={(e) => onMetaChange({ name: e.target.value })}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div>
|
|
127
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-internal`}>
|
|
128
|
+
Internal label
|
|
129
|
+
</label>
|
|
130
|
+
<input
|
|
131
|
+
id={`sec-${section.id}-internal`}
|
|
132
|
+
className={FIELD_INPUT}
|
|
133
|
+
placeholder="Team-only note (optional)"
|
|
134
|
+
value={section.internalLabel ?? ''}
|
|
135
|
+
onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</details>
|
|
140
|
+
</fieldset>
|
|
141
|
+
|
|
142
|
+
{/* The design's locked note, per section rather than once per rail: this
|
|
143
|
+
is where an editor looks for the control that is missing. */}
|
|
144
|
+
<p className="text-muted-foreground mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed">
|
|
145
|
+
<Lock className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
|
146
|
+
<span>
|
|
147
|
+
Background, spacing and layout come from the template.{' '}
|
|
148
|
+
<a
|
|
149
|
+
href="https://github.com/actuate-media/prenta/blob/main/docs/design-owned-sections.md"
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="text-brand inline-flex items-center gap-0.5 hover:underline"
|
|
153
|
+
>
|
|
154
|
+
Why
|
|
155
|
+
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
|
156
|
+
</a>
|
|
157
|
+
</span>
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|