@actuate-media/cms-admin 0.38.1 → 0.40.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 (61) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +25 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts +2 -0
  5. package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/breadcrumbs-is-id.test.js +28 -0
  7. package/dist/__tests__/components/breadcrumbs-is-id.test.js.map +1 -0
  8. package/dist/__tests__/lib/page-editor-service-api.test.js +40 -0
  9. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  10. package/dist/__tests__/views/appearance-settings.render.test.js +34 -0
  11. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/Breadcrumbs.d.ts +1 -0
  14. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  15. package/dist/components/Breadcrumbs.js +10 -2
  16. package/dist/components/Breadcrumbs.js.map +1 -1
  17. package/dist/components/MediaPickerModal.d.ts +9 -1
  18. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  19. package/dist/components/MediaPickerModal.js +11 -5
  20. package/dist/components/MediaPickerModal.js.map +1 -1
  21. package/dist/lib/page-editor-service.d.ts.map +1 -1
  22. package/dist/lib/page-editor-service.js +10 -3
  23. package/dist/lib/page-editor-service.js.map +1 -1
  24. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  25. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  26. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  27. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  28. package/dist/views/page-editor/SectionInspector.js +65 -27
  29. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  30. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  31. package/dist/views/page-editor/SectionsPanel.js +7 -4
  32. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  33. package/dist/views/page-editor/section-types.d.ts +2 -2
  34. package/dist/views/page-editor/section-types.d.ts.map +1 -1
  35. package/dist/views/page-editor/section-types.js +1 -1
  36. package/dist/views/page-editor/section-types.js.map +1 -1
  37. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  38. package/dist/views/page-editor/sections/SectionRenderer.js +14 -1
  39. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  40. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  41. package/dist/views/settings/BrandingCard.js +15 -4
  42. package/dist/views/settings/BrandingCard.js.map +1 -1
  43. package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
  44. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  45. package/dist/views/settings/useAppearanceSettings.js +24 -0
  46. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/AdminRoot.tsx +31 -0
  49. package/src/__tests__/components/breadcrumbs-is-id.test.ts +32 -0
  50. package/src/__tests__/lib/page-editor-service-api.test.ts +43 -0
  51. package/src/__tests__/views/appearance-settings.render.test.tsx +45 -0
  52. package/src/components/Breadcrumbs.tsx +11 -2
  53. package/src/components/MediaPickerModal.tsx +68 -39
  54. package/src/lib/page-editor-service.ts +10 -3
  55. package/src/views/page-editor/PageSectionEditor.tsx +24 -1
  56. package/src/views/page-editor/SectionInspector.tsx +235 -121
  57. package/src/views/page-editor/SectionsPanel.tsx +54 -38
  58. package/src/views/page-editor/section-types.ts +5 -0
  59. package/src/views/page-editor/sections/SectionRenderer.tsx +25 -1
  60. package/src/views/settings/BrandingCard.tsx +37 -2
  61. package/src/views/settings/useAppearanceSettings.ts +39 -0
@@ -1,11 +1,32 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
- import { Eye, EyeOff, Plus, X } from 'lucide-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'
5
21
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
6
22
  import type { PageSection } from '../../lib/page-editor-service.js'
7
- import type { SectionSettings } from './section-types.js'
8
- import { getSectionType, type SectionFieldDef } from './section-types.js'
23
+ import type { ResolvedRepeater, SectionSettings } from './section-types.js'
24
+ import {
25
+ blankRepeaterRow,
26
+ getSectionType,
27
+ resolveRepeaterField,
28
+ type SectionFieldDef,
29
+ } from './section-types.js'
9
30
 
10
31
  interface SectionInspectorProps {
11
32
  section: PageSection
@@ -41,6 +62,53 @@ export function SectionInspector({
41
62
  return () => document.removeEventListener('keydown', onKey)
42
63
  }, [onClose])
43
64
 
65
+ // Unmanaged sections (no registered type) are read-only: show why instead of
66
+ // editable controls, so the externally-managed content is never altered here.
67
+ if (section.unmanaged) {
68
+ return (
69
+ <aside
70
+ role="dialog"
71
+ aria-label={`${section.name} (read-only)`}
72
+ className="bg-card border-border flex h-full w-80 shrink-0 flex-col border-l"
73
+ >
74
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
75
+ <div className="min-w-0">
76
+ <h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
77
+ <p className="text-muted-foreground text-xs">Managed outside the editor</p>
78
+ </div>
79
+ <button
80
+ type="button"
81
+ onClick={onClose}
82
+ aria-label="Close inspector"
83
+ className="text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1"
84
+ >
85
+ <X className="h-4 w-4" aria-hidden />
86
+ </button>
87
+ </div>
88
+ <div className="flex-1 space-y-3 overflow-y-auto p-4">
89
+ <div className="border-border bg-muted text-muted-foreground space-y-2 rounded-md border p-3 text-sm">
90
+ <p className="text-foreground flex items-center gap-2 font-medium">
91
+ <Lock size={14} aria-hidden /> Read-only section
92
+ </p>
93
+ <p>
94
+ The section type{' '}
95
+ <code className="bg-background rounded px-1 py-0.5 text-xs">
96
+ {section.sectionType}
97
+ </code>{' '}
98
+ isn&apos;t registered in this CMS, so it can&apos;t be edited here. It is preserved
99
+ exactly as-is on every save.
100
+ </p>
101
+ <p>
102
+ Edit it in your seed script or code, or register it via{' '}
103
+ <code className="bg-background rounded px-1 py-0.5 text-xs">sections.types</code> in
104
+ your config to make it editable.
105
+ </p>
106
+ </div>
107
+ </div>
108
+ </aside>
109
+ )
110
+ }
111
+
44
112
  return (
45
113
  <aside
46
114
  role="dialog"
@@ -234,20 +302,28 @@ function ContentField({
234
302
  field,
235
303
  value,
236
304
  onChange,
305
+ id: idProp,
237
306
  }: {
238
307
  field: SectionFieldDef
239
308
  value: unknown
240
309
  onChange: (v: unknown) => void
310
+ /** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
311
+ id?: string
241
312
  }) {
242
- const id = `insp-field-${field.key}`
313
+ const id = idProp ?? `insp-field-${field.key}`
243
314
  const text = typeof value === 'string' ? value : ''
244
315
 
245
- if (field.type === 'stats') {
246
- return <StatsField label={field.label} value={value} onChange={onChange} />
247
- }
248
-
249
- if (field.type === 'gallery') {
250
- return <GalleryField label={field.label} value={value} onChange={onChange} />
316
+ const repeater = resolveRepeaterField(field)
317
+ if (repeater) {
318
+ return (
319
+ <RepeaterField
320
+ fieldKey={field.key}
321
+ label={field.label}
322
+ repeater={repeater}
323
+ value={value}
324
+ onChange={onChange}
325
+ />
326
+ )
251
327
  }
252
328
 
253
329
  if (field.type === 'boolean') {
@@ -447,153 +523,191 @@ function RelationshipField({
447
523
  )
448
524
  }
449
525
 
450
- interface ImageRow {
451
- url?: string
452
- alt?: string
453
- caption?: string
454
- }
455
-
456
- function GalleryField({
526
+ type RowRecord = Record<string, unknown>
527
+
528
+ /**
529
+ * Generic repeater editor: an orderable list of rows, each row a small form of
530
+ * the repeater's sub-fields. Drives the generic `repeater` field type and the
531
+ * `stats` / `gallery` shorthands (resolved upstream to the same row model), so
532
+ * there's one editor for every structured-array field.
533
+ *
534
+ * Rows are stored as a plain array on `content[fieldKey]`. Drag/reorder uses an
535
+ * index-derived row id (`row-0`, `row-1`, …); since every edit commits a fresh
536
+ * array this stays consistent and matches the prior stats/gallery behavior.
537
+ */
538
+ function RepeaterField({
539
+ fieldKey,
457
540
  label,
541
+ repeater,
458
542
  value,
459
543
  onChange,
460
544
  }: {
545
+ fieldKey: string
461
546
  label: string
547
+ repeater: ResolvedRepeater
462
548
  value: unknown
463
549
  onChange: (v: unknown) => void
464
550
  }) {
465
- const rows: ImageRow[] = Array.isArray(value) ? (value as ImageRow[]) : []
551
+ const rows: RowRecord[] = Array.isArray(value) ? (value as RowRecord[]) : []
552
+ const atCap = typeof repeater.maxRows === 'number' && rows.length >= repeater.maxRows
553
+ const rowKey = (i: number): string => `row-${i}`
554
+
555
+ const sensors = useSensors(
556
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
557
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
558
+ )
466
559
 
467
- function update(index: number, patch: Partial<ImageRow>) {
468
- onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
560
+ function commit(next: RowRecord[]) {
561
+ onChange(next)
562
+ }
563
+ function update(index: number, patch: RowRecord) {
564
+ commit(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
469
565
  }
470
566
  function remove(index: number) {
471
- onChange(rows.filter((_, i) => i !== index))
567
+ commit(rows.filter((_, i) => i !== index))
472
568
  }
473
569
  function add() {
474
- onChange([...rows, { url: '', alt: '', caption: '' }])
570
+ if (atCap) return
571
+ commit([...rows, blankRepeaterRow(repeater.fields)])
475
572
  }
573
+ function handleDragEnd(event: DragEndEvent) {
574
+ const { active, over } = event
575
+ if (!over || active.id === over.id) return
576
+ // Map the dnd id (index-derived rowKey) back to array positions.
577
+ const ids = rows.map((_, i) => rowKey(i))
578
+ const from = ids.indexOf(String(active.id))
579
+ const to = ids.indexOf(String(over.id))
580
+ if (from === -1 || to === -1) return
581
+ const next = rows.slice()
582
+ const [moved] = next.splice(from, 1)
583
+ next.splice(to, 0, moved!)
584
+ commit(next)
585
+ }
586
+
587
+ const lower = repeater.rowLabel.toLowerCase()
476
588
 
477
589
  return (
478
590
  <div>
479
591
  <span className={LABEL}>{label}</span>
480
- <div className="space-y-3">
481
- {rows.map((row, i) => (
482
- <div key={i} className="border-border rounded-md border p-2">
483
- <div className="mb-2 flex items-center justify-between">
484
- <span className="text-muted-foreground text-xs">Image {i + 1}</span>
485
- <button
486
- type="button"
487
- onClick={() => remove(i)}
488
- aria-label={`Remove image ${i + 1}`}
489
- className="text-muted-foreground hover:text-destructive rounded p-0.5"
490
- >
491
- <X className="h-3.5 w-3.5" aria-hidden />
492
- </button>
493
- </div>
494
- <div className="space-y-2">
495
- <input
496
- className={INPUT}
497
- placeholder="Image URL (https://…)"
498
- value={row.url ?? ''}
499
- onChange={(e) => update(i, { url: e.target.value })}
500
- aria-label={`Image ${i + 1} URL`}
501
- />
502
- <input
503
- className={INPUT}
504
- placeholder="Alt text (for accessibility)"
505
- value={row.alt ?? ''}
506
- onChange={(e) => update(i, { alt: e.target.value })}
507
- aria-label={`Image ${i + 1} alt text`}
508
- />
592
+ {rows.length === 0 ? (
593
+ <p className="border-border text-muted-foreground mb-2 rounded-md border border-dashed px-2 py-3 text-center text-xs">
594
+ No {lower}s yet.
595
+ </p>
596
+ ) : (
597
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
598
+ <SortableContext
599
+ items={rows.map((_, i) => rowKey(i))}
600
+ strategy={verticalListSortingStrategy}
601
+ >
602
+ <div className="space-y-3">
603
+ {rows.map((row, i) => (
604
+ <SortableRepeaterRow
605
+ key={rowKey(i)}
606
+ id={rowKey(i)}
607
+ index={i}
608
+ fieldKey={fieldKey}
609
+ rowLabel={repeater.rowLabel}
610
+ fields={repeater.fields}
611
+ row={row}
612
+ reorderable={rows.length > 1}
613
+ onPatch={(patch) => update(i, patch)}
614
+ onRemove={() => remove(i)}
615
+ />
616
+ ))}
509
617
  </div>
510
- </div>
511
- ))}
512
- </div>
618
+ </SortableContext>
619
+ </DndContext>
620
+ )}
513
621
  <button
514
622
  type="button"
515
623
  onClick={add}
516
- 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"
624
+ disabled={atCap}
625
+ 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"
517
626
  >
518
627
  <Plus className="h-3.5 w-3.5" aria-hidden />
519
- Add image
628
+ Add {lower}
520
629
  </button>
630
+ {atCap && (
631
+ <p className="text-muted-foreground mt-1 text-xs">Maximum of {repeater.maxRows} reached.</p>
632
+ )}
521
633
  </div>
522
634
  )
523
635
  }
524
636
 
525
- interface StatRow {
526
- value?: string
527
- label?: string
528
- description?: string
529
- }
530
-
531
- function StatsField({
532
- label,
533
- value,
534
- onChange,
637
+ function SortableRepeaterRow({
638
+ id,
639
+ index,
640
+ fieldKey,
641
+ rowLabel,
642
+ fields,
643
+ row,
644
+ reorderable,
645
+ onPatch,
646
+ onRemove,
535
647
  }: {
536
- label: string
537
- value: unknown
538
- onChange: (v: unknown) => void
648
+ id: string
649
+ index: number
650
+ fieldKey: string
651
+ rowLabel: string
652
+ fields: SectionFieldDef[]
653
+ row: RowRecord
654
+ reorderable: boolean
655
+ onPatch: (patch: RowRecord) => void
656
+ onRemove: () => void
539
657
  }) {
540
- const rows: StatRow[] = Array.isArray(value) ? (value as StatRow[]) : []
541
-
542
- function update(index: number, patch: Partial<StatRow>) {
543
- const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
544
- onChange(next)
545
- }
546
- function remove(index: number) {
547
- onChange(rows.filter((_, i) => i !== index))
548
- }
549
- function add() {
550
- onChange([...rows, { value: '', label: '', description: '' }])
658
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
659
+ id,
660
+ disabled: !reorderable,
661
+ })
662
+ const style = {
663
+ transform: CSS.Transform.toString(transform),
664
+ transition,
665
+ zIndex: isDragging ? 10 : undefined,
551
666
  }
552
667
 
553
668
  return (
554
- <div>
555
- <span className={LABEL}>{label}</span>
556
- <div className="space-y-3">
557
- {rows.map((row, i) => (
558
- <div key={i} className="border-border rounded-md border p-2">
559
- <div className="mb-2 flex items-center justify-between">
560
- <span className="text-muted-foreground text-xs">Stat {i + 1}</span>
561
- <button
562
- type="button"
563
- onClick={() => remove(i)}
564
- aria-label={`Remove stat ${i + 1}`}
565
- className="text-muted-foreground hover:text-destructive rounded p-0.5"
566
- >
567
- <X className="h-3.5 w-3.5" aria-hidden />
568
- </button>
569
- </div>
570
- <div className="space-y-2">
571
- <input
572
- className={INPUT}
573
- placeholder="Value (e.g. 99.99%)"
574
- value={row.value ?? ''}
575
- onChange={(e) => update(i, { value: e.target.value })}
576
- aria-label={`Stat ${i + 1} value`}
577
- />
578
- <input
579
- className={INPUT}
580
- placeholder="Label (e.g. Uptime)"
581
- value={row.label ?? ''}
582
- onChange={(e) => update(i, { label: e.target.value })}
583
- aria-label={`Stat ${i + 1} label`}
584
- />
585
- </div>
586
- </div>
669
+ <div
670
+ ref={setNodeRef}
671
+ style={style}
672
+ className={`border-border rounded-md border p-2 ${isDragging ? 'bg-card shadow-md' : ''}`}
673
+ >
674
+ <div className="mb-2 flex items-center justify-between">
675
+ <span className="flex items-center gap-1">
676
+ {reorderable && (
677
+ <button
678
+ type="button"
679
+ aria-label={`Drag to reorder ${rowLabel} ${index + 1}`}
680
+ className="text-muted-foreground hover:text-foreground cursor-grab touch-none"
681
+ {...attributes}
682
+ {...listeners}
683
+ >
684
+ <GripVertical className="h-3.5 w-3.5" aria-hidden />
685
+ </button>
686
+ )}
687
+ <span className="text-muted-foreground text-xs">
688
+ {rowLabel} {index + 1}
689
+ </span>
690
+ </span>
691
+ <button
692
+ type="button"
693
+ onClick={onRemove}
694
+ aria-label={`Remove ${rowLabel.toLowerCase()} ${index + 1}`}
695
+ className="text-muted-foreground hover:text-destructive rounded p-0.5"
696
+ >
697
+ <X className="h-3.5 w-3.5" aria-hidden />
698
+ </button>
699
+ </div>
700
+ <div className="space-y-2">
701
+ {fields.map((sub) => (
702
+ <ContentField
703
+ key={sub.key}
704
+ id={`insp-${fieldKey}-${index}-${sub.key}`}
705
+ field={sub}
706
+ value={row[sub.key]}
707
+ onChange={(v) => onPatch({ [sub.key]: v })}
708
+ />
587
709
  ))}
588
710
  </div>
589
- <button
590
- type="button"
591
- onClick={add}
592
- 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"
593
- >
594
- <Plus className="h-3.5 w-3.5" aria-hidden />
595
- Add stat
596
- </button>
597
711
  </div>
598
712
  )
599
713
  }
@@ -5,6 +5,7 @@ import {
5
5
  Eye,
6
6
  EyeOff,
7
7
  GripVertical,
8
+ Lock,
8
9
  type LucideIcon,
9
10
  MoreVertical,
10
11
  Plus,
@@ -153,9 +154,12 @@ function SortableSectionItem({
153
154
  onDuplicate,
154
155
  onRequestDelete,
155
156
  }: SortableItemProps) {
157
+ // Unmanaged (externally-managed) sections are read-only: no drag, duplicate,
158
+ // delete, or visibility toggle — only selectable to view the read-only notice.
159
+ const locked = section.unmanaged === true
156
160
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
157
161
  id: section.id,
158
- disabled: !canEdit,
162
+ disabled: !canEdit || locked,
159
163
  })
160
164
  const Icon = sectionIconFor(section.sectionType)
161
165
 
@@ -176,7 +180,7 @@ function SortableSectionItem({
176
180
  type="button"
177
181
  className="text-muted-foreground hover:text-foreground shrink-0 cursor-grab touch-none disabled:cursor-not-allowed disabled:opacity-40"
178
182
  aria-label={`Drag to reorder ${section.name}`}
179
- disabled={!canEdit}
183
+ disabled={!canEdit || locked}
180
184
  {...attributes}
181
185
  {...listeners}
182
186
  >
@@ -198,48 +202,60 @@ function SortableSectionItem({
198
202
  </span>
199
203
  </button>
200
204
 
201
- <button
202
- type="button"
203
- onClick={() => onToggleVisibility(section.id)}
204
- disabled={!canEdit}
205
- aria-pressed={!section.visible}
206
- aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
207
- className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
208
- >
209
- {section.visible ? (
210
- <Eye className="h-4 w-4" aria-hidden />
211
- ) : (
212
- <EyeOff className="h-4 w-4" aria-hidden />
213
- )}
214
- </button>
215
-
216
- <DropdownMenu.Root>
217
- <DropdownMenu.Trigger asChild>
205
+ {locked ? (
206
+ <span
207
+ className="text-muted-foreground shrink-0 p-1"
208
+ title="Managed outside the editor — read-only"
209
+ aria-label={`${section.name} is managed outside the editor and is read-only`}
210
+ >
211
+ <Lock className="h-4 w-4" aria-hidden />
212
+ </span>
213
+ ) : (
214
+ <>
218
215
  <button
219
216
  type="button"
217
+ onClick={() => onToggleVisibility(section.id)}
220
218
  disabled={!canEdit}
221
- aria-label={`More actions for ${section.name}`}
219
+ aria-pressed={!section.visible}
220
+ aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
222
221
  className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
223
222
  >
224
- <MoreVertical className="h-4 w-4" aria-hidden />
223
+ {section.visible ? (
224
+ <Eye className="h-4 w-4" aria-hidden />
225
+ ) : (
226
+ <EyeOff className="h-4 w-4" aria-hidden />
227
+ )}
225
228
  </button>
226
- </DropdownMenu.Trigger>
227
- <DropdownMenu.Portal>
228
- <DropdownMenu.Content
229
- align="end"
230
- sideOffset={4}
231
- className="border-border bg-popover z-[70] min-w-44 rounded-lg border p-1 shadow-md"
232
- >
233
- <MenuItem onSelect={() => onDuplicate(section.id)} icon={Copy}>
234
- Duplicate
235
- </MenuItem>
236
- <DropdownMenu.Separator className="bg-border my-1 h-px" />
237
- <MenuItem onSelect={() => onRequestDelete(section.id)} icon={Trash2} destructive>
238
- Delete
239
- </MenuItem>
240
- </DropdownMenu.Content>
241
- </DropdownMenu.Portal>
242
- </DropdownMenu.Root>
229
+
230
+ <DropdownMenu.Root>
231
+ <DropdownMenu.Trigger asChild>
232
+ <button
233
+ type="button"
234
+ disabled={!canEdit}
235
+ aria-label={`More actions for ${section.name}`}
236
+ className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
237
+ >
238
+ <MoreVertical className="h-4 w-4" aria-hidden />
239
+ </button>
240
+ </DropdownMenu.Trigger>
241
+ <DropdownMenu.Portal>
242
+ <DropdownMenu.Content
243
+ align="end"
244
+ sideOffset={4}
245
+ className="border-border bg-popover z-[70] min-w-44 rounded-lg border p-1 shadow-md"
246
+ >
247
+ <MenuItem onSelect={() => onDuplicate(section.id)} icon={Copy}>
248
+ Duplicate
249
+ </MenuItem>
250
+ <DropdownMenu.Separator className="bg-border my-1 h-px" />
251
+ <MenuItem onSelect={() => onRequestDelete(section.id)} icon={Trash2} destructive>
252
+ Delete
253
+ </MenuItem>
254
+ </DropdownMenu.Content>
255
+ </DropdownMenu.Portal>
256
+ </DropdownMenu.Root>
257
+ </>
258
+ )}
243
259
  </div>
244
260
  </li>
245
261
  )
@@ -13,6 +13,9 @@ export {
13
13
  getSectionTypesForScope,
14
14
  sectionTypeLabel,
15
15
  isKnownSectionType,
16
+ resolveRepeaterField,
17
+ isRepeaterField,
18
+ blankRepeaterRow,
16
19
  } from '@actuate-media/cms-core/page-sections'
17
20
 
18
21
  export type {
@@ -22,6 +25,8 @@ export type {
22
25
  SectionFieldDef,
23
26
  SectionSettings,
24
27
  SectionTypeDef,
28
+ ResolvedRepeater,
29
+ RepeaterRow,
25
30
  PostHeaderConfig,
26
31
  PostTemplate,
27
32
  } from '@actuate-media/cms-core/page-sections'
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
+ import { Puzzle } from 'lucide-react'
3
4
  import type { PageSection } from '../../../lib/page-editor-service.js'
5
+ import { getSectionType } from '../section-types.js'
4
6
  import { backgroundClass, paddingClass, type PostRenderContext } from './parts.js'
5
7
  import { HeroBannerSection } from './HeroBannerSection.js'
6
8
  import { MissionSection } from './MissionSection.js'
@@ -37,10 +39,32 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
37
39
  case 'gallery':
38
40
  return <GallerySection section={section} />
39
41
  default:
40
- return null
42
+ // Custom (config-declared) types and unmanaged (externally-managed)
43
+ // sections have no built-in body component. Render a neutral placeholder
44
+ // so the canvas reflects their presence + order instead of a blank gap.
45
+ return <PlaceholderSection section={section} />
41
46
  }
42
47
  }
43
48
 
49
+ /** Fallback canvas card for section types without a built-in renderer. */
50
+ function PlaceholderSection({ section }: { section: PageSection }) {
51
+ const def = getSectionType(section.sectionType)
52
+ const label = def?.name ?? section.name
53
+ return (
54
+ <div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
55
+ <span className="bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded-lg">
56
+ <Puzzle className="h-5 w-5" aria-hidden />
57
+ </span>
58
+ <p className="text-foreground text-sm font-medium">{label}</p>
59
+ <p className="text-muted-foreground text-xs">
60
+ {section.unmanaged
61
+ ? `Managed outside the editor (${section.sectionType}) — preview not available here.`
62
+ : `Custom section "${section.sectionType}" — rendered by your site's renderer.`}
63
+ </p>
64
+ </div>
65
+ )
66
+ }
67
+
44
68
  export interface SectionRendererProps {
45
69
  section: PageSection
46
70
  /** Editor mode: adds click-to-select, the selection outline + label, and