@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  4. package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  7. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  8. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  9. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  10. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  11. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  12. package/dist/components/CanvasEditTip.js +1 -1
  13. package/dist/components/CanvasEditTip.js.map +1 -1
  14. package/dist/prenta-admin.css +1 -1
  15. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  16. package/dist/views/page-editor/PageSectionEditor.js +16 -10
  17. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  18. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  19. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  20. package/dist/views/page-editor/SectionEditForm.js +48 -0
  21. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  22. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  23. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  24. package/dist/views/page-editor/SectionInspector.js +5 -223
  25. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  26. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  27. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  28. package/dist/views/page-editor/SectionsPanel.js +11 -5
  29. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  30. package/dist/views/page-editor/section-fields.d.ts +43 -0
  31. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  32. package/dist/views/page-editor/section-fields.js +236 -0
  33. package/dist/views/page-editor/section-fields.js.map +1 -0
  34. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  35. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  37. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  38. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  39. package/dist/views/post-editor/PostSectionEditor.js +11 -9
  40. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  43. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  44. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  45. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  46. package/src/components/CanvasEditTip.tsx +3 -3
  47. package/src/views/page-editor/PageSectionEditor.tsx +22 -24
  48. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  49. package/src/views/page-editor/SectionInspector.tsx +7 -552
  50. package/src/views/page-editor/SectionsPanel.tsx +70 -14
  51. package/src/views/page-editor/section-fields.tsx +563 -0
  52. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  53. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  54. package/src/views/post-editor/PostSectionEditor.tsx +18 -26
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ ChevronDown,
4
5
  Copy,
5
6
  Eye,
6
7
  EyeOff,
@@ -56,6 +57,15 @@ interface SectionsPanelProps {
56
57
  onDuplicate?: (id: string) => void
57
58
  onRequestDelete?: (id: string) => void
58
59
  onAdd?: () => void
60
+ /**
61
+ * Renders the selected section's editor as the body of its own row.
62
+ *
63
+ * A render prop rather than the form itself, so this list stays a list: it
64
+ * knows how to open a row and nothing about what a section's fields are.
65
+ * Omitted by the structural (template) list, which edits through the docked
66
+ * inspector instead.
67
+ */
68
+ renderEditor?: (section: PageSection) => React.ReactNode
59
69
  }
60
70
 
61
71
  /**
@@ -98,6 +108,7 @@ export function SectionsList({
98
108
  onDuplicate,
99
109
  onRequestDelete,
100
110
  onAdd,
111
+ renderEditor,
101
112
  }: SectionsPanelProps) {
102
113
  return (
103
114
  <>
@@ -121,17 +132,30 @@ export function SectionsList({
121
132
  />
122
133
  ) : (
123
134
  <ul className="space-y-1">
124
- {sections.map((section) => (
125
- <SectionRow
126
- key={section.id}
127
- section={section}
128
- selected={section.id === selectedId}
129
- canEdit={canEdit}
130
- structural={false}
131
- onSelect={onSelect}
132
- onToggleVisibility={onToggleVisibility}
133
- />
134
- ))}
135
+ {sections.map((section) => {
136
+ const open = section.id === selectedId
137
+ return (
138
+ <li key={section.id}>
139
+ <SectionRow
140
+ section={section}
141
+ selected={open}
142
+ canEdit={canEdit}
143
+ structural={false}
144
+ expandable={renderEditor !== undefined}
145
+ onSelect={onSelect}
146
+ onToggleVisibility={onToggleVisibility}
147
+ />
148
+ {open && renderEditor && (
149
+ <div
150
+ id={`section-editor-${section.id}`}
151
+ className="border-border overflow-hidden rounded-b-lg border border-t-0"
152
+ >
153
+ {renderEditor(section)}
154
+ </div>
155
+ )}
156
+ </li>
157
+ )
158
+ })}
135
159
  </ul>
136
160
  )}
137
161
  </div>
@@ -244,6 +268,8 @@ interface RowProps {
244
268
  /** Drag affordance, supplied only by the sortable wrapper. */
245
269
  dragHandle?: React.ReactNode
246
270
  dragging?: boolean
271
+ /** The row opens an editor beneath it, so it gets a disclosure control. */
272
+ expandable?: boolean
247
273
  }
248
274
 
249
275
  function SectionRow({
@@ -251,6 +277,7 @@ function SectionRow({
251
277
  selected,
252
278
  canEdit,
253
279
  structural,
280
+ expandable = false,
254
281
  onSelect,
255
282
  onToggleVisibility,
256
283
  onDuplicate,
@@ -266,15 +293,21 @@ function SectionRow({
266
293
 
267
294
  return (
268
295
  <div
269
- className={`group flex items-center gap-2 rounded-lg border px-2 py-2 ${
270
- selected ? 'border-primary bg-accent' : 'hover:bg-accent/60 border-transparent'
271
- } ${dragging ? 'shadow-md' : ''}`}
296
+ className={`group flex items-center gap-2 border px-2 py-2 ${
297
+ // Square off the bottom while the editor is open beneath, so row and
298
+ // form read as one object rather than two stacked cards.
299
+ selected && expandable ? 'rounded-t-lg' : 'rounded-lg'
300
+ } ${selected ? 'border-primary bg-accent' : 'hover:bg-accent/60 border-transparent'} ${
301
+ dragging ? 'shadow-md' : ''
302
+ }`}
272
303
  >
273
304
  {dragHandle}
274
305
 
275
306
  <button
276
307
  type="button"
277
308
  onClick={() => onSelect(section.id)}
309
+ aria-expanded={expandable ? selected : undefined}
310
+ aria-controls={expandable && selected ? `section-editor-${section.id}` : undefined}
278
311
  className="flex min-w-0 flex-1 items-center gap-2 text-left"
279
312
  >
280
313
  <span className="bg-muted text-muted-foreground flex h-7 w-7 shrink-0 items-center justify-center rounded-md">
@@ -314,6 +347,29 @@ function SectionRow({
314
347
  )}
315
348
  </button>
316
349
 
350
+ {/* Disclosure. The row itself already toggles, but a visible chevron
351
+ is what says the row opens at all — without one the list looks
352
+ like a set of links to somewhere else, which is what it used to
353
+ be when the editor lived in a docked panel. */}
354
+ {expandable && (
355
+ <button
356
+ type="button"
357
+ onClick={() => onSelect(section.id)}
358
+ aria-expanded={selected}
359
+ aria-controls={selected ? `section-editor-${section.id}` : undefined}
360
+ aria-label={selected ? `Close ${section.name}` : `Edit ${section.name}`}
361
+ title="Edit content"
362
+ className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1"
363
+ >
364
+ <ChevronDown
365
+ className={`h-4 w-4 transition-transform motion-reduce:transition-none ${
366
+ selected ? 'rotate-180' : ''
367
+ }`}
368
+ aria-hidden
369
+ />
370
+ </button>
371
+ )}
372
+
317
373
  {structural && (
318
374
  <DropdownMenu.Root>
319
375
  <DropdownMenu.Trigger asChild>
@@ -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
+ }