@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +25 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts +2 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.d.ts.map +1 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.js +28 -0
- package/dist/__tests__/components/breadcrumbs-is-id.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +40 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/views/appearance-settings.render.test.js +34 -0
- package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts +1 -0
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +10 -2
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/MediaPickerModal.d.ts +9 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +11 -5
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +10 -3
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +5 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +65 -27
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +7 -4
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/section-types.d.ts +2 -2
- package/dist/views/page-editor/section-types.d.ts.map +1 -1
- package/dist/views/page-editor/section-types.js +1 -1
- package/dist/views/page-editor/section-types.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +14 -1
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/settings/BrandingCard.d.ts.map +1 -1
- package/dist/views/settings/BrandingCard.js +15 -4
- package/dist/views/settings/BrandingCard.js.map +1 -1
- package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
- package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
- package/dist/views/settings/useAppearanceSettings.js +24 -0
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +31 -0
- package/src/__tests__/components/breadcrumbs-is-id.test.ts +32 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +43 -0
- package/src/__tests__/views/appearance-settings.render.test.tsx +45 -0
- package/src/components/Breadcrumbs.tsx +11 -2
- package/src/components/MediaPickerModal.tsx +68 -39
- package/src/lib/page-editor-service.ts +10 -3
- package/src/views/page-editor/PageSectionEditor.tsx +24 -1
- package/src/views/page-editor/SectionInspector.tsx +235 -121
- package/src/views/page-editor/SectionsPanel.tsx +54 -38
- package/src/views/page-editor/section-types.ts +5 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +25 -1
- package/src/views/settings/BrandingCard.tsx +37 -2
- 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 {
|
|
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't registered in this CMS, so it can'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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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:
|
|
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
|
|
468
|
-
onChange(
|
|
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
|
-
|
|
567
|
+
commit(rows.filter((_, i) => i !== index))
|
|
472
568
|
}
|
|
473
569
|
function add() {
|
|
474
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
</
|
|
511
|
-
|
|
512
|
-
|
|
618
|
+
</SortableContext>
|
|
619
|
+
</DndContext>
|
|
620
|
+
)}
|
|
513
621
|
<button
|
|
514
622
|
type="button"
|
|
515
623
|
onClick={add}
|
|
516
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
637
|
+
function SortableRepeaterRow({
|
|
638
|
+
id,
|
|
639
|
+
index,
|
|
640
|
+
fieldKey,
|
|
641
|
+
rowLabel,
|
|
642
|
+
fields,
|
|
643
|
+
row,
|
|
644
|
+
reorderable,
|
|
645
|
+
onPatch,
|
|
646
|
+
onRemove,
|
|
535
647
|
}: {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|