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