@prenta/admin 0.93.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 +152 -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__/lib/version-diff.test.d.ts +2 -0
- package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-diff.test.js +230 -0
- package/dist/__tests__/lib/version-diff.test.js.map +1 -0
- package/dist/__tests__/lib/version-history.test.d.ts +2 -0
- package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-history.test.js +125 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.js +127 -0
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
- package/dist/__tests__/views/editor-top-bar.render.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/__tests__/views/version-history-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
- package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/components/EditorRail.js +3 -3
- package/dist/components/EditorRail.js.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.js +5 -1
- package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
- package/dist/layout/primitives/AdminShell.js +2 -2
- package/dist/layout/primitives/AdminShell.js.map +1 -1
- package/dist/lib/version-diff.d.ts +50 -0
- package/dist/lib/version-diff.d.ts.map +1 -0
- package/dist/lib/version-diff.js +294 -0
- package/dist/lib/version-diff.js.map +1 -0
- package/dist/lib/version-history.d.ts +73 -0
- package/dist/lib/version-history.d.ts.map +1 -0
- package/dist/lib/version-history.js +126 -0
- package/dist/lib/version-history.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +3 -3
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +8 -6
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +129 -40
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/ReviewBar.d.ts +21 -0
- package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
- package/dist/views/page-editor/ReviewBar.js +22 -0
- package/dist/views/page-editor/ReviewBar.js.map +1 -0
- 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 +6 -224
- 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/VersionHistoryPanel.d.ts +29 -0
- package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
- package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
- package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
- 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/page-editor/sections/SectionRenderer.d.ts +13 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
- package/dist/views/page-editor/sections/SectionRenderer.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__/lib/version-diff.test.ts +278 -0
- package/src/__tests__/lib/version-history.test.ts +166 -0
- package/src/__tests__/styles/responsive-layer.test.ts +152 -0
- package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -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/__tests__/views/version-history-panel.render.test.tsx +195 -0
- package/src/components/CanvasEditTip.tsx +3 -3
- package/src/components/EditorRail.tsx +5 -5
- package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
- package/src/layout/primitives/AdminShell.tsx +13 -1
- package/src/lib/version-diff.ts +376 -0
- package/src/lib/version-history.ts +192 -0
- package/src/styles/responsive.css +281 -0
- package/src/styles/theme.css +8 -0
- package/src/views/page-editor/EditorCanvas.tsx +34 -7
- package/src/views/page-editor/EditorTopBar.tsx +20 -8
- package/src/views/page-editor/PageSectionEditor.tsx +182 -65
- package/src/views/page-editor/ReviewBar.tsx +76 -0
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +9 -553
- package/src/views/page-editor/SectionsPanel.tsx +72 -16
- package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +18 -26
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema-driven field renderers for a section's content.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `SectionInspector` so the inline editor in the rail and the
|
|
7
|
+
* template editor's inspector render fields through ONE implementation. The
|
|
8
|
+
* two surfaces disagree about chrome — an accordion body versus a docked panel
|
|
9
|
+
* — and about nothing else, and this codebase has twice paid for a second copy
|
|
10
|
+
* of a renderer drifting from the first (see the `paddingClass` and
|
|
11
|
+
* mirror-renderer entries in docs/page-builder-handoff-followups.md).
|
|
12
|
+
*/
|
|
13
|
+
import { GripVertical, Plus, X } from 'lucide-react'
|
|
14
|
+
import {
|
|
15
|
+
DndContext,
|
|
16
|
+
KeyboardSensor,
|
|
17
|
+
PointerSensor,
|
|
18
|
+
closestCenter,
|
|
19
|
+
useSensor,
|
|
20
|
+
useSensors,
|
|
21
|
+
type DragEndEvent,
|
|
22
|
+
} from '@dnd-kit/core'
|
|
23
|
+
import {
|
|
24
|
+
SortableContext,
|
|
25
|
+
sortableKeyboardCoordinates,
|
|
26
|
+
useSortable,
|
|
27
|
+
verticalListSortingStrategy,
|
|
28
|
+
} from '@dnd-kit/sortable'
|
|
29
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
30
|
+
import { MediaBlock } from '../../components/MediaBlock.js'
|
|
31
|
+
import { ReflowMeter } from '../../components/ReflowMeter.js'
|
|
32
|
+
import { Toggle } from '../../components/ui/Toggle.js'
|
|
33
|
+
import { useReflowGuard } from '../../hooks/useReflowGuard.js'
|
|
34
|
+
import { baselineFieldText } from '../../lib/reflow-baseline.js'
|
|
35
|
+
import { useReflowContext } from './reflow-context.js'
|
|
36
|
+
import type { ResolvedRepeater } from './section-types.js'
|
|
37
|
+
import { blankRepeaterRow, resolveRepeaterField, type SectionFieldDef } from './section-types.js'
|
|
38
|
+
|
|
39
|
+
/** Shared control classes, so every field surface looks identical. */
|
|
40
|
+
export const FIELD_LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
41
|
+
export const FIELD_INPUT =
|
|
42
|
+
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Field types the reflow guard measures.
|
|
46
|
+
*
|
|
47
|
+
* Text that wraps, and nothing else. A `select` has a fixed set of values the
|
|
48
|
+
* design already accounts for; a `boolean` has no text; `media` wraps nothing.
|
|
49
|
+
*
|
|
50
|
+
* `richText` is excluded deliberately. The probe establishes a baseline by
|
|
51
|
+
* writing the published string into the cloned element, which for rich text
|
|
52
|
+
* would mean injecting stored markup into the DOM — and the whole body is one
|
|
53
|
+
* marker, so a line count across several paragraphs answers no question an
|
|
54
|
+
* editor is asking.
|
|
55
|
+
*/
|
|
56
|
+
const GUARDED_FIELD_TYPES = new Set(['text', 'textarea', 'url'])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attaches the reflow readout under a field.
|
|
60
|
+
*
|
|
61
|
+
* Renders nothing at all when the guard has no rows — the section may be drawn
|
|
62
|
+
* by a consumer's renderer that carries no `data-cms-field` markers, and an
|
|
63
|
+
* empty row of dots would read as "measured, all fine" rather than "not
|
|
64
|
+
* measured".
|
|
65
|
+
*/
|
|
66
|
+
function FieldReflow({ fieldPath, value }: { fieldPath: string; value: string }) {
|
|
67
|
+
const ctx = useReflowContext()
|
|
68
|
+
const guard = useReflowGuard({
|
|
69
|
+
canvas: ctx.canvas,
|
|
70
|
+
sectionId: ctx.sectionId ?? '',
|
|
71
|
+
fieldPath,
|
|
72
|
+
value,
|
|
73
|
+
baseline: baselineFieldText(ctx.baseline, ctx.sectionId ?? '', fieldPath),
|
|
74
|
+
enabled: Boolean(ctx.canvas && ctx.sectionId),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (guard.rows.length === 0) return null
|
|
78
|
+
return (
|
|
79
|
+
<ReflowMeter guard={guard} length={value.length} device={ctx.device} onProof={ctx.onProof} />
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ContentField({
|
|
84
|
+
field,
|
|
85
|
+
value,
|
|
86
|
+
onChange,
|
|
87
|
+
onPatch,
|
|
88
|
+
id: idProp,
|
|
89
|
+
fieldPath,
|
|
90
|
+
}: {
|
|
91
|
+
field: SectionFieldDef
|
|
92
|
+
value: unknown
|
|
93
|
+
onChange: (v: unknown) => void
|
|
94
|
+
/**
|
|
95
|
+
* Optional multi-key patch sink — lets media fields persist companion
|
|
96
|
+
* dimension keys (width/height) atomically with the URL.
|
|
97
|
+
*/
|
|
98
|
+
onPatch?: (patch: Record<string, unknown>) => void
|
|
99
|
+
/** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
|
|
100
|
+
id?: string
|
|
101
|
+
/**
|
|
102
|
+
* Dotted `data-cms-field` path for the reflow guard. Differs from `field.key`
|
|
103
|
+
* inside a repeater, where the marker is `stats.0.label`. Defaults to the
|
|
104
|
+
* key, which is right for every top-level field.
|
|
105
|
+
*/
|
|
106
|
+
fieldPath?: string
|
|
107
|
+
}) {
|
|
108
|
+
const id = idProp ?? `insp-field-${field.key}`
|
|
109
|
+
const path = fieldPath ?? field.key
|
|
110
|
+
const text = typeof value === 'string' ? value : ''
|
|
111
|
+
const guarded = GUARDED_FIELD_TYPES.has(field.type)
|
|
112
|
+
|
|
113
|
+
const repeater = resolveRepeaterField(field)
|
|
114
|
+
if (repeater) {
|
|
115
|
+
return (
|
|
116
|
+
<RepeaterField
|
|
117
|
+
fieldKey={field.key}
|
|
118
|
+
label={field.label}
|
|
119
|
+
repeater={repeater}
|
|
120
|
+
value={value}
|
|
121
|
+
onChange={onChange}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (field.type === 'boolean') {
|
|
127
|
+
return <BooleanField id={id} field={field} value={value === true} onChange={onChange} />
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (field.type === 'relationship') {
|
|
131
|
+
return <RelationshipField id={id} field={field} value={value} onChange={onChange} />
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (field.type === 'media') {
|
|
135
|
+
return <MediaField id={id} field={field} value={text} onChange={onChange} onPatch={onPatch} />
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (field.type === 'select') {
|
|
139
|
+
return (
|
|
140
|
+
<div>
|
|
141
|
+
<label className={FIELD_LABEL} htmlFor={id}>
|
|
142
|
+
{field.label}
|
|
143
|
+
</label>
|
|
144
|
+
<select
|
|
145
|
+
id={id}
|
|
146
|
+
className={FIELD_INPUT}
|
|
147
|
+
value={text}
|
|
148
|
+
onChange={(e) => onChange(e.target.value)}
|
|
149
|
+
>
|
|
150
|
+
{field.options?.map((o) => (
|
|
151
|
+
<option key={o.value} value={o.value}>
|
|
152
|
+
{o.label}
|
|
153
|
+
</option>
|
|
154
|
+
))}
|
|
155
|
+
</select>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (field.type === 'textarea' || field.type === 'richText') {
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
<label className={FIELD_LABEL} htmlFor={id}>
|
|
164
|
+
{field.label}
|
|
165
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
166
|
+
</label>
|
|
167
|
+
<textarea
|
|
168
|
+
id={id}
|
|
169
|
+
className={`${FIELD_INPUT} resize-y ${field.type === 'richText' ? 'min-h-[140px]' : 'min-h-[72px]'}`}
|
|
170
|
+
value={text}
|
|
171
|
+
placeholder={field.placeholder}
|
|
172
|
+
onChange={(e) => onChange(e.target.value)}
|
|
173
|
+
/>
|
|
174
|
+
{guarded && <FieldReflow fieldPath={path} value={text} />}
|
|
175
|
+
{field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div>
|
|
182
|
+
<label className={FIELD_LABEL} htmlFor={id}>
|
|
183
|
+
{field.label}
|
|
184
|
+
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
|
185
|
+
</label>
|
|
186
|
+
<input
|
|
187
|
+
id={id}
|
|
188
|
+
type={field.type === 'url' ? 'url' : 'text'}
|
|
189
|
+
className={FIELD_INPUT}
|
|
190
|
+
value={text}
|
|
191
|
+
placeholder={field.placeholder}
|
|
192
|
+
onChange={(e) => onChange(e.target.value)}
|
|
193
|
+
/>
|
|
194
|
+
{guarded && <FieldReflow fieldPath={path} value={text} />}
|
|
195
|
+
{field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Companion content keys that hold an image field's intrinsic dimensions.
|
|
202
|
+
* Picking from the media library persists width/height alongside the URL so the
|
|
203
|
+
* public renderer can use the CLS-safe `next/image` path without waiting on
|
|
204
|
+
* read-time `/resolve` enrichment. Mirrors the keys the section renderers read:
|
|
205
|
+
* `imageUrl` → `imageWidth` / `imageHeight`; `url` (gallery row) → `width` / `height`.
|
|
206
|
+
*/
|
|
207
|
+
export function companionDimensionKeys(fieldKey: string): { width: string; height: string } {
|
|
208
|
+
if (fieldKey === 'url') return { width: 'width', height: 'height' }
|
|
209
|
+
if (fieldKey.endsWith('Url')) {
|
|
210
|
+
const base = fieldKey.slice(0, -'Url'.length)
|
|
211
|
+
return { width: `${base}Width`, height: `${base}Height` }
|
|
212
|
+
}
|
|
213
|
+
return { width: `${fieldKey}Width`, height: `${fieldKey}Height` }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function MediaField({
|
|
217
|
+
id,
|
|
218
|
+
field,
|
|
219
|
+
value,
|
|
220
|
+
onChange,
|
|
221
|
+
onPatch,
|
|
222
|
+
}: {
|
|
223
|
+
id: string
|
|
224
|
+
field: SectionFieldDef
|
|
225
|
+
value: string
|
|
226
|
+
onChange: (v: unknown) => void
|
|
227
|
+
/**
|
|
228
|
+
* When provided, picking from the library applies a multi-key patch (URL +
|
|
229
|
+
* intrinsic width/height) in one update. Falls back to `onChange` (URL only)
|
|
230
|
+
* when no patch sink is wired.
|
|
231
|
+
*/
|
|
232
|
+
onPatch?: (patch: Record<string, unknown>) => void
|
|
233
|
+
}) {
|
|
234
|
+
return (
|
|
235
|
+
<div>
|
|
236
|
+
{/* Not `htmlFor` — the block is a composite of buttons, not a single
|
|
237
|
+
labellable control. The label names the group; each control inside
|
|
238
|
+
carries its own accessible name. */}
|
|
239
|
+
<span className={FIELD_LABEL} id={`${id}-label`}>
|
|
240
|
+
{field.label}
|
|
241
|
+
</span>
|
|
242
|
+
<MediaBlock
|
|
243
|
+
value={value}
|
|
244
|
+
label={field.label}
|
|
245
|
+
onChange={(url, item) => {
|
|
246
|
+
if (!onPatch) {
|
|
247
|
+
onChange(url)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
// Persist the intrinsic dimensions alongside the URL so the public
|
|
251
|
+
// renderer can use the CLS-safe path without a read-time lookup.
|
|
252
|
+
const { width: wKey, height: hKey } = companionDimensionKeys(field.key)
|
|
253
|
+
const patch: Record<string, unknown> = { [field.key]: url }
|
|
254
|
+
if (typeof item?.width === 'number') patch[wKey] = item.width
|
|
255
|
+
if (typeof item?.height === 'number') patch[hKey] = item.height
|
|
256
|
+
// Clearing the slot clears the dimensions with it — leaving them
|
|
257
|
+
// behind would size the next image by the previous one's box.
|
|
258
|
+
if (!url) {
|
|
259
|
+
patch[wKey] = undefined
|
|
260
|
+
patch[hKey] = undefined
|
|
261
|
+
}
|
|
262
|
+
onPatch(patch)
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
{field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function BooleanField({
|
|
271
|
+
id,
|
|
272
|
+
field,
|
|
273
|
+
value,
|
|
274
|
+
onChange,
|
|
275
|
+
}: {
|
|
276
|
+
id: string
|
|
277
|
+
field: SectionFieldDef
|
|
278
|
+
value: boolean
|
|
279
|
+
onChange: (v: unknown) => void
|
|
280
|
+
}) {
|
|
281
|
+
return (
|
|
282
|
+
<div>
|
|
283
|
+
<div className="border-border text-foreground flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm">
|
|
284
|
+
<span>{field.label}</span>
|
|
285
|
+
<Toggle
|
|
286
|
+
id={id}
|
|
287
|
+
checked={value}
|
|
288
|
+
onCheckedChange={(v) => onChange(v)}
|
|
289
|
+
aria-label={field.label}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
{field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function RelationshipField({
|
|
298
|
+
id,
|
|
299
|
+
field,
|
|
300
|
+
value,
|
|
301
|
+
onChange,
|
|
302
|
+
}: {
|
|
303
|
+
id: string
|
|
304
|
+
field: SectionFieldDef
|
|
305
|
+
value: unknown
|
|
306
|
+
onChange: (v: unknown) => void
|
|
307
|
+
}) {
|
|
308
|
+
// Stored as an array of slugs; edited as a newline/comma-separated list
|
|
309
|
+
// so authors can hand-pick referenced documents without a live picker.
|
|
310
|
+
const slugs = Array.isArray(value) ? (value as string[]) : []
|
|
311
|
+
const text = slugs.join('\n')
|
|
312
|
+
return (
|
|
313
|
+
<div>
|
|
314
|
+
<label className={FIELD_LABEL} htmlFor={id}>
|
|
315
|
+
{field.label}
|
|
316
|
+
</label>
|
|
317
|
+
<textarea
|
|
318
|
+
id={id}
|
|
319
|
+
className={`${FIELD_INPUT} min-h-[72px] resize-y`}
|
|
320
|
+
placeholder="one-slug-per-line"
|
|
321
|
+
value={text}
|
|
322
|
+
onChange={(e) =>
|
|
323
|
+
onChange(
|
|
324
|
+
e.target.value
|
|
325
|
+
.split(/[\n,]/)
|
|
326
|
+
.map((s) => s.trim())
|
|
327
|
+
.filter(Boolean),
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
/>
|
|
331
|
+
{field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
type RowRecord = Record<string, unknown>
|
|
337
|
+
|
|
338
|
+
// Field types whose values are short enough to scan in a row header. Long-form
|
|
339
|
+
// fields (textarea, richText) and non-text values (media, url) stay out.
|
|
340
|
+
const SUMMARY_FIELD_TYPES = new Set(['text', 'select'])
|
|
341
|
+
const SUMMARY_MAX_LENGTH = 60
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Derive a scannable one-line summary for a repeater row from its first two
|
|
345
|
+
* short text values — e.g. a pricing row renders as "Pro · $79/mo" instead of
|
|
346
|
+
* "Plan 2". Returns `null` when the row has no usable values yet, so the
|
|
347
|
+
* caller falls back to the positional "Plan 2" label.
|
|
348
|
+
*/
|
|
349
|
+
export function repeaterRowSummary(fields: SectionFieldDef[], row: RowRecord): string | null {
|
|
350
|
+
const parts: string[] = []
|
|
351
|
+
for (const field of fields) {
|
|
352
|
+
if (parts.length >= 2) break
|
|
353
|
+
if (!SUMMARY_FIELD_TYPES.has(field.type)) continue
|
|
354
|
+
const raw = row[field.key]
|
|
355
|
+
const value = typeof raw === 'string' ? raw.trim() : typeof raw === 'number' ? String(raw) : ''
|
|
356
|
+
if (!value) continue
|
|
357
|
+
if (field.type === 'select') {
|
|
358
|
+
const option = field.options?.find((o) => o.value === value)
|
|
359
|
+
parts.push(option?.label ?? value)
|
|
360
|
+
} else {
|
|
361
|
+
parts.push(value)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (parts.length === 0) return null
|
|
365
|
+
const joined = parts.join(' · ')
|
|
366
|
+
return joined.length > SUMMARY_MAX_LENGTH
|
|
367
|
+
? `${joined.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…`
|
|
368
|
+
: joined
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Generic repeater editor: an orderable list of rows, each row a small form of
|
|
373
|
+
* the repeater's sub-fields. Drives the generic `repeater` field type and the
|
|
374
|
+
* `stats` / `gallery` shorthands (resolved upstream to the same row model), so
|
|
375
|
+
* there's one editor for every structured-array field.
|
|
376
|
+
*
|
|
377
|
+
* Rows are stored as a plain array on `content[fieldKey]`. Drag/reorder uses an
|
|
378
|
+
* index-derived row id (`row-0`, `row-1`, …); since every edit commits a fresh
|
|
379
|
+
* array this stays consistent and matches the prior stats/gallery behavior.
|
|
380
|
+
*/
|
|
381
|
+
function RepeaterField({
|
|
382
|
+
fieldKey,
|
|
383
|
+
label,
|
|
384
|
+
repeater,
|
|
385
|
+
value,
|
|
386
|
+
onChange,
|
|
387
|
+
}: {
|
|
388
|
+
fieldKey: string
|
|
389
|
+
label: string
|
|
390
|
+
repeater: ResolvedRepeater
|
|
391
|
+
value: unknown
|
|
392
|
+
onChange: (v: unknown) => void
|
|
393
|
+
}) {
|
|
394
|
+
const rows: RowRecord[] = Array.isArray(value) ? (value as RowRecord[]) : []
|
|
395
|
+
const atCap = typeof repeater.maxRows === 'number' && rows.length >= repeater.maxRows
|
|
396
|
+
const rowKey = (i: number): string => `row-${i}`
|
|
397
|
+
|
|
398
|
+
const sensors = useSensors(
|
|
399
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
400
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
function commit(next: RowRecord[]) {
|
|
404
|
+
onChange(next)
|
|
405
|
+
}
|
|
406
|
+
function update(index: number, patch: RowRecord) {
|
|
407
|
+
commit(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
|
|
408
|
+
}
|
|
409
|
+
function remove(index: number) {
|
|
410
|
+
commit(rows.filter((_, i) => i !== index))
|
|
411
|
+
}
|
|
412
|
+
function add() {
|
|
413
|
+
if (atCap) return
|
|
414
|
+
commit([...rows, blankRepeaterRow(repeater.fields)])
|
|
415
|
+
}
|
|
416
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
417
|
+
const { active, over } = event
|
|
418
|
+
if (!over || active.id === over.id) return
|
|
419
|
+
// Map the dnd id (index-derived rowKey) back to array positions.
|
|
420
|
+
const ids = rows.map((_, i) => rowKey(i))
|
|
421
|
+
const from = ids.indexOf(String(active.id))
|
|
422
|
+
const to = ids.indexOf(String(over.id))
|
|
423
|
+
if (from === -1 || to === -1) return
|
|
424
|
+
const next = rows.slice()
|
|
425
|
+
const [moved] = next.splice(from, 1)
|
|
426
|
+
next.splice(to, 0, moved!)
|
|
427
|
+
commit(next)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const lower = repeater.rowLabel.toLowerCase()
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div>
|
|
434
|
+
<span className={FIELD_LABEL}>{label}</span>
|
|
435
|
+
{rows.length === 0 ? (
|
|
436
|
+
<p className="border-border text-muted-foreground mb-2 rounded-md border border-dashed px-2 py-3 text-center text-xs">
|
|
437
|
+
No {lower}s yet.
|
|
438
|
+
</p>
|
|
439
|
+
) : (
|
|
440
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
441
|
+
<SortableContext
|
|
442
|
+
items={rows.map((_, i) => rowKey(i))}
|
|
443
|
+
strategy={verticalListSortingStrategy}
|
|
444
|
+
>
|
|
445
|
+
<div className="space-y-3">
|
|
446
|
+
{rows.map((row, i) => (
|
|
447
|
+
<SortableRepeaterRow
|
|
448
|
+
key={rowKey(i)}
|
|
449
|
+
id={rowKey(i)}
|
|
450
|
+
index={i}
|
|
451
|
+
fieldKey={fieldKey}
|
|
452
|
+
rowLabel={repeater.rowLabel}
|
|
453
|
+
fields={repeater.fields}
|
|
454
|
+
row={row}
|
|
455
|
+
reorderable={rows.length > 1}
|
|
456
|
+
onPatch={(patch) => update(i, patch)}
|
|
457
|
+
onRemove={() => remove(i)}
|
|
458
|
+
/>
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
</SortableContext>
|
|
462
|
+
</DndContext>
|
|
463
|
+
)}
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
onClick={add}
|
|
467
|
+
disabled={atCap}
|
|
468
|
+
className="border-border text-foreground hover:bg-accent mt-2 flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed px-2 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
|
469
|
+
>
|
|
470
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
471
|
+
Add {lower}
|
|
472
|
+
</button>
|
|
473
|
+
{atCap && (
|
|
474
|
+
<p className="text-muted-foreground mt-1 text-xs">Maximum of {repeater.maxRows} reached.</p>
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function SortableRepeaterRow({
|
|
481
|
+
id,
|
|
482
|
+
index,
|
|
483
|
+
fieldKey,
|
|
484
|
+
rowLabel,
|
|
485
|
+
fields,
|
|
486
|
+
row,
|
|
487
|
+
reorderable,
|
|
488
|
+
onPatch,
|
|
489
|
+
onRemove,
|
|
490
|
+
}: {
|
|
491
|
+
id: string
|
|
492
|
+
index: number
|
|
493
|
+
fieldKey: string
|
|
494
|
+
rowLabel: string
|
|
495
|
+
fields: SectionFieldDef[]
|
|
496
|
+
row: RowRecord
|
|
497
|
+
reorderable: boolean
|
|
498
|
+
onPatch: (patch: RowRecord) => void
|
|
499
|
+
onRemove: () => void
|
|
500
|
+
}) {
|
|
501
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
502
|
+
id,
|
|
503
|
+
disabled: !reorderable,
|
|
504
|
+
})
|
|
505
|
+
const style = {
|
|
506
|
+
transform: CSS.Transform.toString(transform),
|
|
507
|
+
transition,
|
|
508
|
+
zIndex: isDragging ? 10 : undefined,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const summary = repeaterRowSummary(fields, row)
|
|
512
|
+
|
|
513
|
+
return (
|
|
514
|
+
<div
|
|
515
|
+
ref={setNodeRef}
|
|
516
|
+
style={style}
|
|
517
|
+
className={`border-border rounded-md border p-2 ${isDragging ? 'bg-card shadow-md' : ''}`}
|
|
518
|
+
>
|
|
519
|
+
<div className="mb-2 flex items-center justify-between">
|
|
520
|
+
<span className="flex items-center gap-1">
|
|
521
|
+
{reorderable && (
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
aria-label={`Drag to reorder ${rowLabel} ${index + 1}`}
|
|
525
|
+
className="text-muted-foreground hover:text-foreground cursor-grab touch-none"
|
|
526
|
+
{...attributes}
|
|
527
|
+
{...listeners}
|
|
528
|
+
>
|
|
529
|
+
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
|
530
|
+
</button>
|
|
531
|
+
)}
|
|
532
|
+
<span
|
|
533
|
+
className="text-muted-foreground max-w-[200px] truncate text-xs"
|
|
534
|
+
title={summary ? `${rowLabel} ${index + 1}: ${summary}` : undefined}
|
|
535
|
+
>
|
|
536
|
+
{summary ?? `${rowLabel} ${index + 1}`}
|
|
537
|
+
</span>
|
|
538
|
+
</span>
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
onClick={onRemove}
|
|
542
|
+
aria-label={`Remove ${rowLabel.toLowerCase()} ${index + 1}`}
|
|
543
|
+
className="text-muted-foreground hover:text-destructive rounded p-0.5"
|
|
544
|
+
>
|
|
545
|
+
<X className="h-3.5 w-3.5" aria-hidden />
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
<div className="space-y-2">
|
|
549
|
+
{fields.map((sub) => (
|
|
550
|
+
<ContentField
|
|
551
|
+
key={sub.key}
|
|
552
|
+
id={`insp-field-${fieldKey}-${index}-${sub.key}`}
|
|
553
|
+
fieldPath={`${fieldKey}.${index}.${sub.key}`}
|
|
554
|
+
field={sub}
|
|
555
|
+
value={row[sub.key]}
|
|
556
|
+
onChange={(v) => onPatch({ [sub.key]: v })}
|
|
557
|
+
onPatch={(patch) => onPatch(patch)}
|
|
558
|
+
/>
|
|
559
|
+
))}
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
)
|
|
563
|
+
}
|
|
@@ -42,7 +42,7 @@ export function ArticleBodySection({
|
|
|
42
42
|
<p className="text-muted-foreground text-base leading-relaxed italic">
|
|
43
43
|
{source === 'field'
|
|
44
44
|
? "This will show the post's Body content."
|
|
45
|
-
: 'Add body copy in the
|
|
45
|
+
: 'Add body copy in the section list.'}
|
|
46
46
|
</p>
|
|
47
47
|
)}
|
|
48
48
|
</div>
|
|
@@ -164,6 +164,18 @@ export interface SectionRendererProps {
|
|
|
164
164
|
registerRef?: (id: string, el: HTMLElement | null) => void
|
|
165
165
|
/** Live post data for post-oriented section renderers (pages omit this). */
|
|
166
166
|
context?: PostRenderContext
|
|
167
|
+
/**
|
|
168
|
+
* Reviewing a past version: the canvas is a read-only render of a snapshot.
|
|
169
|
+
*
|
|
170
|
+
* A SEPARATE flag from `editable`, not a second use of it. Both modes
|
|
171
|
+
* suppress click-to-select and the hover outline, so it is tempting to fold
|
|
172
|
+
* them together — but review mode has to keep showing a label on the
|
|
173
|
+
* sections that changed, which is the entire reason it exists. One shared
|
|
174
|
+
* suppression rule hides exactly the thing the mode is for.
|
|
175
|
+
*/
|
|
176
|
+
reviewing?: boolean
|
|
177
|
+
/** This section differs from the version immediately before the one shown. */
|
|
178
|
+
changed?: boolean
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
/**
|
|
@@ -179,41 +191,70 @@ export function SectionRenderer({
|
|
|
179
191
|
onSelect,
|
|
180
192
|
registerRef,
|
|
181
193
|
context,
|
|
194
|
+
reviewing = false,
|
|
195
|
+
changed = false,
|
|
182
196
|
}: SectionRendererProps) {
|
|
183
197
|
const settings = section.settings ?? {}
|
|
184
|
-
|
|
198
|
+
// A snapshot's hidden sections stay muted: the page is still being shown as
|
|
199
|
+
// the editor's own view of it, and a hidden section that rendered solid here
|
|
200
|
+
// would misreport what was live at that moment.
|
|
201
|
+
const chromed = editable || reviewing
|
|
202
|
+
const hidden = chromed && !section.visible
|
|
203
|
+
// Review mode is read-only. Click-to-select would edit the CURRENT page
|
|
204
|
+
// while the canvas shows a past one.
|
|
205
|
+
const interactive = editable && !reviewing
|
|
185
206
|
|
|
186
207
|
return (
|
|
187
208
|
<section
|
|
188
209
|
ref={(el) => registerRef?.(section.id, el)}
|
|
189
210
|
data-section-id={section.id}
|
|
190
|
-
data-editable={
|
|
191
|
-
data-selected={selected ? 'true' : undefined}
|
|
211
|
+
data-editable={interactive ? 'true' : undefined}
|
|
212
|
+
data-selected={interactive && selected ? 'true' : undefined}
|
|
213
|
+
data-reviewing={reviewing ? 'true' : undefined}
|
|
214
|
+
data-changed={reviewing && changed ? 'true' : undefined}
|
|
192
215
|
id={settings.anchorId || undefined}
|
|
193
216
|
className={`relative ${backgroundClass(settings)} ${paddingClass(settings)} ${
|
|
194
217
|
settings.cssClass ?? ''
|
|
195
218
|
} ${hidden ? 'opacity-40' : ''} ${
|
|
196
|
-
|
|
219
|
+
interactive
|
|
197
220
|
? 'cursor-pointer outline-2 -outline-offset-2 motion-safe:transition-[outline-color] motion-safe:duration-150'
|
|
198
221
|
: ''
|
|
199
222
|
} ${
|
|
200
|
-
|
|
201
|
-
?
|
|
202
|
-
|
|
203
|
-
? 'outline-transparent hover:outline hover:outline-[color-mix(in_oklab,var(--acc)_40%,transparent)]'
|
|
223
|
+
reviewing
|
|
224
|
+
? changed
|
|
225
|
+
? 'outline-warning outline-2 -outline-offset-2 outline-dashed'
|
|
204
226
|
: 'outline-transparent'
|
|
227
|
+
: selected
|
|
228
|
+
? 'outline outline-violet-500'
|
|
229
|
+
: interactive
|
|
230
|
+
? 'outline-transparent hover:outline hover:outline-[color-mix(in_oklab,var(--acc)_40%,transparent)]'
|
|
231
|
+
: 'outline-transparent'
|
|
205
232
|
}`}
|
|
206
|
-
onClick={
|
|
233
|
+
onClick={interactive ? () => onSelect?.(section.id) : undefined}
|
|
207
234
|
// Selection is a convenience in the editor; the section list is the
|
|
208
235
|
// primary, fully-accessible control surface, so this stays a region.
|
|
209
|
-
aria-label={
|
|
236
|
+
aria-label={
|
|
237
|
+
reviewing && changed
|
|
238
|
+
? `${section.name} section — changed in this version`
|
|
239
|
+
: interactive
|
|
240
|
+
? `${section.name} section`
|
|
241
|
+
: undefined
|
|
242
|
+
}
|
|
210
243
|
>
|
|
211
|
-
{
|
|
244
|
+
{interactive && selected && (
|
|
212
245
|
<span className="bg-brand pointer-events-none absolute top-0 left-0 z-10 rounded-br-md px-2 py-0.5 text-xs font-medium text-white">
|
|
213
246
|
{section.name}
|
|
214
247
|
</span>
|
|
215
248
|
)}
|
|
216
|
-
{
|
|
249
|
+
{/* Persistent, not hover-revealed: while reviewing, this label IS the
|
|
250
|
+
finding. Unchanged sections get no label at all, so the marked ones
|
|
251
|
+
are legible at a glance down a scaled-down page. */}
|
|
252
|
+
{reviewing && changed && (
|
|
253
|
+
<span className="bg-warning text-warning-foreground pointer-events-none absolute top-0 left-0 z-10 rounded-br-md px-2 py-0.5 text-xs font-medium">
|
|
254
|
+
{section.name} — changed
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
{chromed && hidden && (
|
|
217
258
|
<span className="bg-foreground pointer-events-none absolute top-0 right-0 z-10 rounded-bl-md px-2 py-0.5 text-xs font-medium text-white">
|
|
218
259
|
Hidden
|
|
219
260
|
</span>
|
|
@@ -129,7 +129,7 @@ function EmptyBody({ onAddSection }: { onAddSection?: () => void }) {
|
|
|
129
129
|
</span>
|
|
130
130
|
<p className="text-muted-foreground text-sm">
|
|
131
131
|
{onAddSection
|
|
132
|
-
? 'The header is set by the template. Add body sections to build out this post. Hover sections for a light outline, click to
|
|
132
|
+
? 'The header is set by the template. Add body sections to build out this post. Hover sections for a light outline, click to open its editor in the section list — or use Site preview to click text and images in place.'
|
|
133
133
|
: 'This post’s template renders no body sections. Layout is design-owned — sections come from the template in code. Pick a different template in Post settings, or edit the template itself.'}
|
|
134
134
|
</p>
|
|
135
135
|
{onAddSection && (
|