@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +152 -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__/lib/version-diff.test.d.ts +2 -0
  5. package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
  6. package/dist/__tests__/lib/version-diff.test.js +230 -0
  7. package/dist/__tests__/lib/version-diff.test.js.map +1 -0
  8. package/dist/__tests__/lib/version-history.test.d.ts +2 -0
  9. package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/version-history.test.js +125 -0
  11. package/dist/__tests__/lib/version-history.test.js.map +1 -0
  12. package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
  13. package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
  14. package/dist/__tests__/styles/responsive-layer.test.js +127 -0
  15. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
  16. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
  19. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
  20. package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
  21. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  22. package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  25. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  26. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  27. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  28. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  29. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  30. package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
  31. package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
  32. package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
  33. package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
  34. package/dist/components/CanvasEditTip.js +1 -1
  35. package/dist/components/CanvasEditTip.js.map +1 -1
  36. package/dist/components/EditorRail.js +3 -3
  37. package/dist/components/EditorRail.js.map +1 -1
  38. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
  39. package/dist/components/page-tab/DirectiveSwitch.js +5 -1
  40. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
  41. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  42. package/dist/layout/primitives/AdminShell.js +2 -2
  43. package/dist/layout/primitives/AdminShell.js.map +1 -1
  44. package/dist/lib/version-diff.d.ts +50 -0
  45. package/dist/lib/version-diff.d.ts.map +1 -0
  46. package/dist/lib/version-diff.js +294 -0
  47. package/dist/lib/version-diff.js.map +1 -0
  48. package/dist/lib/version-history.d.ts +73 -0
  49. package/dist/lib/version-history.d.ts.map +1 -0
  50. package/dist/lib/version-history.js +126 -0
  51. package/dist/lib/version-history.js.map +1 -0
  52. package/dist/prenta-admin.css +1 -1
  53. package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
  54. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  55. package/dist/views/page-editor/EditorCanvas.js +3 -3
  56. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  57. package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
  58. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  59. package/dist/views/page-editor/EditorTopBar.js +8 -6
  60. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  62. package/dist/views/page-editor/PageSectionEditor.js +129 -40
  63. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  64. package/dist/views/page-editor/ReviewBar.d.ts +21 -0
  65. package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
  66. package/dist/views/page-editor/ReviewBar.js +22 -0
  67. package/dist/views/page-editor/ReviewBar.js.map +1 -0
  68. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  69. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  70. package/dist/views/page-editor/SectionEditForm.js +48 -0
  71. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  72. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  73. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  74. package/dist/views/page-editor/SectionInspector.js +6 -224
  75. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  76. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  77. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  78. package/dist/views/page-editor/SectionsPanel.js +11 -5
  79. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  80. package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
  81. package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
  82. package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
  83. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
  84. package/dist/views/page-editor/section-fields.d.ts +43 -0
  85. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  86. package/dist/views/page-editor/section-fields.js +236 -0
  87. package/dist/views/page-editor/section-fields.js.map +1 -0
  88. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  89. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  90. package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
  91. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  92. package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
  93. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  94. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  95. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  97. package/dist/views/post-editor/PostSectionEditor.js +11 -9
  98. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  99. package/package.json +3 -3
  100. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  101. package/src/__tests__/lib/version-diff.test.ts +278 -0
  102. package/src/__tests__/lib/version-history.test.ts +166 -0
  103. package/src/__tests__/styles/responsive-layer.test.ts +152 -0
  104. package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
  105. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
  106. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  107. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  108. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  109. package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
  110. package/src/components/CanvasEditTip.tsx +3 -3
  111. package/src/components/EditorRail.tsx +5 -5
  112. package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
  113. package/src/layout/primitives/AdminShell.tsx +13 -1
  114. package/src/lib/version-diff.ts +376 -0
  115. package/src/lib/version-history.ts +192 -0
  116. package/src/styles/responsive.css +281 -0
  117. package/src/styles/theme.css +8 -0
  118. package/src/views/page-editor/EditorCanvas.tsx +34 -7
  119. package/src/views/page-editor/EditorTopBar.tsx +20 -8
  120. package/src/views/page-editor/PageSectionEditor.tsx +182 -65
  121. package/src/views/page-editor/ReviewBar.tsx +76 -0
  122. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  123. package/src/views/page-editor/SectionInspector.tsx +9 -553
  124. package/src/views/page-editor/SectionsPanel.tsx +72 -16
  125. package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
  126. package/src/views/page-editor/section-fields.tsx +563 -0
  127. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  128. package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
  129. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  130. package/src/views/post-editor/PostSectionEditor.tsx +18 -26
@@ -1,39 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
4
- import { Eye, EyeOff, GripVertical, Lock, Plus, X } from 'lucide-react'
5
- import {
6
- DndContext,
7
- KeyboardSensor,
8
- PointerSensor,
9
- closestCenter,
10
- useSensor,
11
- useSensors,
12
- type DragEndEvent,
13
- } from '@dnd-kit/core'
14
- import {
15
- SortableContext,
16
- sortableKeyboardCoordinates,
17
- useSortable,
18
- verticalListSortingStrategy,
19
- } from '@dnd-kit/sortable'
20
- import { CSS } from '@dnd-kit/utilities'
21
- import { MediaBlock } from '../../components/MediaBlock.js'
22
- import { ReflowMeter } from '../../components/ReflowMeter.js'
3
+ import { useEffect } from 'react'
4
+ import { Eye, EyeOff, Lock, X } from 'lucide-react'
23
5
  import { InspectorExpandButton, ResizableAside } from '../../components/ui/ResizableAside.js'
24
- import { Toggle } from '../../components/ui/Toggle.js'
6
+ import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
25
7
  import { RichTextField } from '../../fields/RichTextField.js'
26
- import { useReflowGuard } from '../../hooks/useReflowGuard.js'
27
- import { baselineFieldText } from '../../lib/reflow-baseline.js'
28
8
  import type { PageSection } from '../../lib/page-editor-service.js'
29
- import { useReflowContext } from './reflow-context.js'
30
- import type { ResolvedRepeater, SectionSettings } from './section-types.js'
31
- import {
32
- blankRepeaterRow,
33
- getSectionType,
34
- resolveRepeaterField,
35
- type SectionFieldDef,
36
- } from './section-types.js'
9
+ import type { SectionSettings } from './section-types.js'
10
+ import { getSectionType } from './section-types.js'
37
11
 
38
12
  interface SectionInspectorProps {
39
13
  section: PageSection
@@ -48,10 +22,10 @@ interface SectionInspectorProps {
48
22
  onPostBodyChange?: (html: string) => void
49
23
  }
50
24
 
51
- const LABEL = 'text-foreground mb-1 block text-xs font-medium'
52
- const INPUT =
53
- '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'
54
- const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
25
+ const LABEL = FIELD_LABEL
26
+ const INPUT = FIELD_INPUT
27
+ const INSPECTOR_SHELL =
28
+ 'prenta-editor-inspector bg-card border-border flex h-full flex-col border-l'
55
29
 
56
30
  function InspectorHeaderActions({
57
31
  onClose,
@@ -329,521 +303,3 @@ export function SectionInspector({
329
303
  </ResizableAside>
330
304
  )
331
305
  }
332
-
333
- // ─── Field renderers ─────────────────────────────────────────────────
334
-
335
- /**
336
- * Field types the reflow guard measures.
337
- *
338
- * Text that wraps, and nothing else. A `select` has a fixed set of values the
339
- * design already accounts for; a `boolean` has no text; `media` wraps nothing.
340
- *
341
- * `richText` is excluded deliberately. The probe establishes a baseline by
342
- * writing the published string into the cloned element, which for rich text
343
- * would mean injecting stored markup into the DOM — and the whole body is one
344
- * marker, so a line count across several paragraphs answers no question an
345
- * editor is asking.
346
- */
347
- const GUARDED_FIELD_TYPES = new Set(['text', 'textarea', 'url'])
348
-
349
- /**
350
- * Attaches the reflow readout under a field.
351
- *
352
- * Renders nothing at all when the guard has no rows — the section may be drawn
353
- * by a consumer's renderer that carries no `data-cms-field` markers, and an
354
- * empty row of dots would read as "measured, all fine" rather than "not
355
- * measured".
356
- */
357
- function FieldReflow({ fieldPath, value }: { fieldPath: string; value: string }) {
358
- const ctx = useReflowContext()
359
- const guard = useReflowGuard({
360
- canvas: ctx.canvas,
361
- sectionId: ctx.sectionId ?? '',
362
- fieldPath,
363
- value,
364
- baseline: baselineFieldText(ctx.baseline, ctx.sectionId ?? '', fieldPath),
365
- enabled: Boolean(ctx.canvas && ctx.sectionId),
366
- })
367
-
368
- if (guard.rows.length === 0) return null
369
- return (
370
- <ReflowMeter guard={guard} length={value.length} device={ctx.device} onProof={ctx.onProof} />
371
- )
372
- }
373
-
374
- function ContentField({
375
- field,
376
- value,
377
- onChange,
378
- onPatch,
379
- id: idProp,
380
- fieldPath,
381
- }: {
382
- field: SectionFieldDef
383
- value: unknown
384
- onChange: (v: unknown) => void
385
- /**
386
- * Optional multi-key patch sink — lets media fields persist companion
387
- * dimension keys (width/height) atomically with the URL.
388
- */
389
- onPatch?: (patch: Record<string, unknown>) => void
390
- /** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
391
- id?: string
392
- /**
393
- * Dotted `data-cms-field` path for the reflow guard. Differs from `field.key`
394
- * inside a repeater, where the marker is `stats.0.label`. Defaults to the
395
- * key, which is right for every top-level field.
396
- */
397
- fieldPath?: string
398
- }) {
399
- const id = idProp ?? `insp-field-${field.key}`
400
- const path = fieldPath ?? field.key
401
- const text = typeof value === 'string' ? value : ''
402
- const guarded = GUARDED_FIELD_TYPES.has(field.type)
403
-
404
- const repeater = resolveRepeaterField(field)
405
- if (repeater) {
406
- return (
407
- <RepeaterField
408
- fieldKey={field.key}
409
- label={field.label}
410
- repeater={repeater}
411
- value={value}
412
- onChange={onChange}
413
- />
414
- )
415
- }
416
-
417
- if (field.type === 'boolean') {
418
- return <BooleanField id={id} field={field} value={value === true} onChange={onChange} />
419
- }
420
-
421
- if (field.type === 'relationship') {
422
- return <RelationshipField id={id} field={field} value={value} onChange={onChange} />
423
- }
424
-
425
- if (field.type === 'media') {
426
- return <MediaField id={id} field={field} value={text} onChange={onChange} onPatch={onPatch} />
427
- }
428
-
429
- if (field.type === 'select') {
430
- return (
431
- <div>
432
- <label className={LABEL} htmlFor={id}>
433
- {field.label}
434
- </label>
435
- <select id={id} className={INPUT} value={text} onChange={(e) => onChange(e.target.value)}>
436
- {field.options?.map((o) => (
437
- <option key={o.value} value={o.value}>
438
- {o.label}
439
- </option>
440
- ))}
441
- </select>
442
- </div>
443
- )
444
- }
445
-
446
- if (field.type === 'textarea' || field.type === 'richText') {
447
- return (
448
- <div>
449
- <label className={LABEL} htmlFor={id}>
450
- {field.label}
451
- {field.required && <span className="text-destructive ml-0.5">*</span>}
452
- </label>
453
- <textarea
454
- id={id}
455
- className={`${INPUT} resize-y ${field.type === 'richText' ? 'min-h-[140px]' : 'min-h-[72px]'}`}
456
- value={text}
457
- placeholder={field.placeholder}
458
- onChange={(e) => onChange(e.target.value)}
459
- />
460
- {guarded && <FieldReflow fieldPath={path} value={text} />}
461
- {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
462
- </div>
463
- )
464
- }
465
-
466
- return (
467
- <div>
468
- <label className={LABEL} htmlFor={id}>
469
- {field.label}
470
- {field.required && <span className="text-destructive ml-0.5">*</span>}
471
- </label>
472
- <input
473
- id={id}
474
- type={field.type === 'url' ? 'url' : 'text'}
475
- className={INPUT}
476
- value={text}
477
- placeholder={field.placeholder}
478
- onChange={(e) => onChange(e.target.value)}
479
- />
480
- {guarded && <FieldReflow fieldPath={path} value={text} />}
481
- {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
482
- </div>
483
- )
484
- }
485
-
486
- /**
487
- * Companion content keys that hold an image field's intrinsic dimensions.
488
- * Picking from the media library persists width/height alongside the URL so the
489
- * public renderer can use the CLS-safe `next/image` path without waiting on
490
- * read-time `/resolve` enrichment. Mirrors the keys the section renderers read:
491
- * `imageUrl` → `imageWidth` / `imageHeight`; `url` (gallery row) → `width` / `height`.
492
- */
493
- export function companionDimensionKeys(fieldKey: string): { width: string; height: string } {
494
- if (fieldKey === 'url') return { width: 'width', height: 'height' }
495
- if (fieldKey.endsWith('Url')) {
496
- const base = fieldKey.slice(0, -'Url'.length)
497
- return { width: `${base}Width`, height: `${base}Height` }
498
- }
499
- return { width: `${fieldKey}Width`, height: `${fieldKey}Height` }
500
- }
501
-
502
- function MediaField({
503
- id,
504
- field,
505
- value,
506
- onChange,
507
- onPatch,
508
- }: {
509
- id: string
510
- field: SectionFieldDef
511
- value: string
512
- onChange: (v: unknown) => void
513
- /**
514
- * When provided, picking from the library applies a multi-key patch (URL +
515
- * intrinsic width/height) in one update. Falls back to `onChange` (URL only)
516
- * when no patch sink is wired.
517
- */
518
- onPatch?: (patch: Record<string, unknown>) => void
519
- }) {
520
- return (
521
- <div>
522
- {/* Not `htmlFor` — the block is a composite of buttons, not a single
523
- labellable control. The label names the group; each control inside
524
- carries its own accessible name. */}
525
- <span className={LABEL} id={`${id}-label`}>
526
- {field.label}
527
- </span>
528
- <MediaBlock
529
- value={value}
530
- label={field.label}
531
- onChange={(url, item) => {
532
- if (!onPatch) {
533
- onChange(url)
534
- return
535
- }
536
- // Persist the intrinsic dimensions alongside the URL so the public
537
- // renderer can use the CLS-safe path without a read-time lookup.
538
- const { width: wKey, height: hKey } = companionDimensionKeys(field.key)
539
- const patch: Record<string, unknown> = { [field.key]: url }
540
- if (typeof item?.width === 'number') patch[wKey] = item.width
541
- if (typeof item?.height === 'number') patch[hKey] = item.height
542
- // Clearing the slot clears the dimensions with it — leaving them
543
- // behind would size the next image by the previous one's box.
544
- if (!url) {
545
- patch[wKey] = undefined
546
- patch[hKey] = undefined
547
- }
548
- onPatch(patch)
549
- }}
550
- />
551
- {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
552
- </div>
553
- )
554
- }
555
-
556
- function BooleanField({
557
- id,
558
- field,
559
- value,
560
- onChange,
561
- }: {
562
- id: string
563
- field: SectionFieldDef
564
- value: boolean
565
- onChange: (v: unknown) => void
566
- }) {
567
- return (
568
- <div>
569
- <div className="border-border text-foreground flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm">
570
- <span>{field.label}</span>
571
- <Toggle
572
- id={id}
573
- checked={value}
574
- onCheckedChange={(v) => onChange(v)}
575
- aria-label={field.label}
576
- />
577
- </div>
578
- {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
579
- </div>
580
- )
581
- }
582
-
583
- function RelationshipField({
584
- id,
585
- field,
586
- value,
587
- onChange,
588
- }: {
589
- id: string
590
- field: SectionFieldDef
591
- value: unknown
592
- onChange: (v: unknown) => void
593
- }) {
594
- // Stored as an array of slugs; edited as a newline/comma-separated list
595
- // so authors can hand-pick referenced documents without a live picker.
596
- const slugs = Array.isArray(value) ? (value as string[]) : []
597
- const text = slugs.join('\n')
598
- return (
599
- <div>
600
- <label className={LABEL} htmlFor={id}>
601
- {field.label}
602
- </label>
603
- <textarea
604
- id={id}
605
- className={`${INPUT} min-h-[72px] resize-y`}
606
- placeholder="one-slug-per-line"
607
- value={text}
608
- onChange={(e) =>
609
- onChange(
610
- e.target.value
611
- .split(/[\n,]/)
612
- .map((s) => s.trim())
613
- .filter(Boolean),
614
- )
615
- }
616
- />
617
- {field.help && <p className="text-muted-foreground mt-1 text-xs">{field.help}</p>}
618
- </div>
619
- )
620
- }
621
-
622
- type RowRecord = Record<string, unknown>
623
-
624
- // Field types whose values are short enough to scan in a row header. Long-form
625
- // fields (textarea, richText) and non-text values (media, url) stay out.
626
- const SUMMARY_FIELD_TYPES = new Set(['text', 'select'])
627
- const SUMMARY_MAX_LENGTH = 60
628
-
629
- /**
630
- * Derive a scannable one-line summary for a repeater row from its first two
631
- * short text values — e.g. a pricing row renders as "Pro · $79/mo" instead of
632
- * "Plan 2". Returns `null` when the row has no usable values yet, so the
633
- * caller falls back to the positional "Plan 2" label.
634
- */
635
- export function repeaterRowSummary(fields: SectionFieldDef[], row: RowRecord): string | null {
636
- const parts: string[] = []
637
- for (const field of fields) {
638
- if (parts.length >= 2) break
639
- if (!SUMMARY_FIELD_TYPES.has(field.type)) continue
640
- const raw = row[field.key]
641
- const value = typeof raw === 'string' ? raw.trim() : typeof raw === 'number' ? String(raw) : ''
642
- if (!value) continue
643
- if (field.type === 'select') {
644
- const option = field.options?.find((o) => o.value === value)
645
- parts.push(option?.label ?? value)
646
- } else {
647
- parts.push(value)
648
- }
649
- }
650
- if (parts.length === 0) return null
651
- const joined = parts.join(' · ')
652
- return joined.length > SUMMARY_MAX_LENGTH
653
- ? `${joined.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…`
654
- : joined
655
- }
656
-
657
- /**
658
- * Generic repeater editor: an orderable list of rows, each row a small form of
659
- * the repeater's sub-fields. Drives the generic `repeater` field type and the
660
- * `stats` / `gallery` shorthands (resolved upstream to the same row model), so
661
- * there's one editor for every structured-array field.
662
- *
663
- * Rows are stored as a plain array on `content[fieldKey]`. Drag/reorder uses an
664
- * index-derived row id (`row-0`, `row-1`, …); since every edit commits a fresh
665
- * array this stays consistent and matches the prior stats/gallery behavior.
666
- */
667
- function RepeaterField({
668
- fieldKey,
669
- label,
670
- repeater,
671
- value,
672
- onChange,
673
- }: {
674
- fieldKey: string
675
- label: string
676
- repeater: ResolvedRepeater
677
- value: unknown
678
- onChange: (v: unknown) => void
679
- }) {
680
- const rows: RowRecord[] = Array.isArray(value) ? (value as RowRecord[]) : []
681
- const atCap = typeof repeater.maxRows === 'number' && rows.length >= repeater.maxRows
682
- const rowKey = (i: number): string => `row-${i}`
683
-
684
- const sensors = useSensors(
685
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
686
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
687
- )
688
-
689
- function commit(next: RowRecord[]) {
690
- onChange(next)
691
- }
692
- function update(index: number, patch: RowRecord) {
693
- commit(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
694
- }
695
- function remove(index: number) {
696
- commit(rows.filter((_, i) => i !== index))
697
- }
698
- function add() {
699
- if (atCap) return
700
- commit([...rows, blankRepeaterRow(repeater.fields)])
701
- }
702
- function handleDragEnd(event: DragEndEvent) {
703
- const { active, over } = event
704
- if (!over || active.id === over.id) return
705
- // Map the dnd id (index-derived rowKey) back to array positions.
706
- const ids = rows.map((_, i) => rowKey(i))
707
- const from = ids.indexOf(String(active.id))
708
- const to = ids.indexOf(String(over.id))
709
- if (from === -1 || to === -1) return
710
- const next = rows.slice()
711
- const [moved] = next.splice(from, 1)
712
- next.splice(to, 0, moved!)
713
- commit(next)
714
- }
715
-
716
- const lower = repeater.rowLabel.toLowerCase()
717
-
718
- return (
719
- <div>
720
- <span className={LABEL}>{label}</span>
721
- {rows.length === 0 ? (
722
- <p className="border-border text-muted-foreground mb-2 rounded-md border border-dashed px-2 py-3 text-center text-xs">
723
- No {lower}s yet.
724
- </p>
725
- ) : (
726
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
727
- <SortableContext
728
- items={rows.map((_, i) => rowKey(i))}
729
- strategy={verticalListSortingStrategy}
730
- >
731
- <div className="space-y-3">
732
- {rows.map((row, i) => (
733
- <SortableRepeaterRow
734
- key={rowKey(i)}
735
- id={rowKey(i)}
736
- index={i}
737
- fieldKey={fieldKey}
738
- rowLabel={repeater.rowLabel}
739
- fields={repeater.fields}
740
- row={row}
741
- reorderable={rows.length > 1}
742
- onPatch={(patch) => update(i, patch)}
743
- onRemove={() => remove(i)}
744
- />
745
- ))}
746
- </div>
747
- </SortableContext>
748
- </DndContext>
749
- )}
750
- <button
751
- type="button"
752
- onClick={add}
753
- disabled={atCap}
754
- 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"
755
- >
756
- <Plus className="h-3.5 w-3.5" aria-hidden />
757
- Add {lower}
758
- </button>
759
- {atCap && (
760
- <p className="text-muted-foreground mt-1 text-xs">Maximum of {repeater.maxRows} reached.</p>
761
- )}
762
- </div>
763
- )
764
- }
765
-
766
- function SortableRepeaterRow({
767
- id,
768
- index,
769
- fieldKey,
770
- rowLabel,
771
- fields,
772
- row,
773
- reorderable,
774
- onPatch,
775
- onRemove,
776
- }: {
777
- id: string
778
- index: number
779
- fieldKey: string
780
- rowLabel: string
781
- fields: SectionFieldDef[]
782
- row: RowRecord
783
- reorderable: boolean
784
- onPatch: (patch: RowRecord) => void
785
- onRemove: () => void
786
- }) {
787
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
788
- id,
789
- disabled: !reorderable,
790
- })
791
- const style = {
792
- transform: CSS.Transform.toString(transform),
793
- transition,
794
- zIndex: isDragging ? 10 : undefined,
795
- }
796
-
797
- const summary = repeaterRowSummary(fields, row)
798
-
799
- return (
800
- <div
801
- ref={setNodeRef}
802
- style={style}
803
- className={`border-border rounded-md border p-2 ${isDragging ? 'bg-card shadow-md' : ''}`}
804
- >
805
- <div className="mb-2 flex items-center justify-between">
806
- <span className="flex items-center gap-1">
807
- {reorderable && (
808
- <button
809
- type="button"
810
- aria-label={`Drag to reorder ${rowLabel} ${index + 1}`}
811
- className="text-muted-foreground hover:text-foreground cursor-grab touch-none"
812
- {...attributes}
813
- {...listeners}
814
- >
815
- <GripVertical className="h-3.5 w-3.5" aria-hidden />
816
- </button>
817
- )}
818
- <span
819
- className="text-muted-foreground max-w-[200px] truncate text-xs"
820
- title={summary ? `${rowLabel} ${index + 1}: ${summary}` : undefined}
821
- >
822
- {summary ?? `${rowLabel} ${index + 1}`}
823
- </span>
824
- </span>
825
- <button
826
- type="button"
827
- onClick={onRemove}
828
- aria-label={`Remove ${rowLabel.toLowerCase()} ${index + 1}`}
829
- className="text-muted-foreground hover:text-destructive rounded p-0.5"
830
- >
831
- <X className="h-3.5 w-3.5" aria-hidden />
832
- </button>
833
- </div>
834
- <div className="space-y-2">
835
- {fields.map((sub) => (
836
- <ContentField
837
- key={sub.key}
838
- id={`insp-field-${fieldKey}-${index}-${sub.key}`}
839
- fieldPath={`${fieldKey}.${index}.${sub.key}`}
840
- field={sub}
841
- value={row[sub.key]}
842
- onChange={(v) => onPatch({ [sub.key]: v })}
843
- onPatch={(patch) => onPatch(patch)}
844
- />
845
- ))}
846
- </div>
847
- </div>
848
- )
849
- }