@actuate-media/cms-admin 0.39.0 → 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 +22 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/page-editor-service.js +1 -1
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +61 -28
- package/dist/views/page-editor/SectionInspector.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/package.json +2 -2
- package/src/AdminRoot.tsx +23 -4
- package/src/lib/page-editor-service.ts +1 -1
- package/src/views/page-editor/SectionInspector.tsx +190 -125
- package/src/views/page-editor/section-types.ts +5 -0
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
-
import { Eye, EyeOff, Lock, 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
|
|
@@ -79,10 +100,8 @@ export function SectionInspector({
|
|
|
79
100
|
</p>
|
|
80
101
|
<p>
|
|
81
102
|
Edit it in your seed script or code, or register it via{' '}
|
|
82
|
-
<code className="bg-background rounded px-1 py-0.5 text-xs">
|
|
83
|
-
|
|
84
|
-
</code>{' '}
|
|
85
|
-
in your config to make it editable.
|
|
103
|
+
<code className="bg-background rounded px-1 py-0.5 text-xs">sections.types</code> in
|
|
104
|
+
your config to make it editable.
|
|
86
105
|
</p>
|
|
87
106
|
</div>
|
|
88
107
|
</div>
|
|
@@ -283,20 +302,28 @@ function ContentField({
|
|
|
283
302
|
field,
|
|
284
303
|
value,
|
|
285
304
|
onChange,
|
|
305
|
+
id: idProp,
|
|
286
306
|
}: {
|
|
287
307
|
field: SectionFieldDef
|
|
288
308
|
value: unknown
|
|
289
309
|
onChange: (v: unknown) => void
|
|
310
|
+
/** Explicit input id (repeater rows pass a row-scoped id to stay unique). */
|
|
311
|
+
id?: string
|
|
290
312
|
}) {
|
|
291
|
-
const id = `insp-field-${field.key}`
|
|
313
|
+
const id = idProp ?? `insp-field-${field.key}`
|
|
292
314
|
const text = typeof value === 'string' ? value : ''
|
|
293
315
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
)
|
|
300
327
|
}
|
|
301
328
|
|
|
302
329
|
if (field.type === 'boolean') {
|
|
@@ -496,153 +523,191 @@ function RelationshipField({
|
|
|
496
523
|
)
|
|
497
524
|
}
|
|
498
525
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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,
|
|
506
540
|
label,
|
|
541
|
+
repeater,
|
|
507
542
|
value,
|
|
508
543
|
onChange,
|
|
509
544
|
}: {
|
|
545
|
+
fieldKey: string
|
|
510
546
|
label: string
|
|
547
|
+
repeater: ResolvedRepeater
|
|
511
548
|
value: unknown
|
|
512
549
|
onChange: (v: unknown) => void
|
|
513
550
|
}) {
|
|
514
|
-
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}`
|
|
515
554
|
|
|
516
|
-
|
|
517
|
-
|
|
555
|
+
const sensors = useSensors(
|
|
556
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
557
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
558
|
+
)
|
|
559
|
+
|
|
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)))
|
|
518
565
|
}
|
|
519
566
|
function remove(index: number) {
|
|
520
|
-
|
|
567
|
+
commit(rows.filter((_, i) => i !== index))
|
|
521
568
|
}
|
|
522
569
|
function add() {
|
|
523
|
-
|
|
570
|
+
if (atCap) return
|
|
571
|
+
commit([...rows, blankRepeaterRow(repeater.fields)])
|
|
524
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()
|
|
525
588
|
|
|
526
589
|
return (
|
|
527
590
|
<div>
|
|
528
591
|
<span className={LABEL}>{label}</span>
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
value={row.alt ?? ''}
|
|
555
|
-
onChange={(e) => update(i, { alt: e.target.value })}
|
|
556
|
-
aria-label={`Image ${i + 1} alt text`}
|
|
557
|
-
/>
|
|
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
|
+
))}
|
|
558
617
|
</div>
|
|
559
|
-
</
|
|
560
|
-
|
|
561
|
-
|
|
618
|
+
</SortableContext>
|
|
619
|
+
</DndContext>
|
|
620
|
+
)}
|
|
562
621
|
<button
|
|
563
622
|
type="button"
|
|
564
623
|
onClick={add}
|
|
565
|
-
|
|
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"
|
|
566
626
|
>
|
|
567
627
|
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
568
|
-
Add
|
|
628
|
+
Add {lower}
|
|
569
629
|
</button>
|
|
630
|
+
{atCap && (
|
|
631
|
+
<p className="text-muted-foreground mt-1 text-xs">Maximum of {repeater.maxRows} reached.</p>
|
|
632
|
+
)}
|
|
570
633
|
</div>
|
|
571
634
|
)
|
|
572
635
|
}
|
|
573
636
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
637
|
+
function SortableRepeaterRow({
|
|
638
|
+
id,
|
|
639
|
+
index,
|
|
640
|
+
fieldKey,
|
|
641
|
+
rowLabel,
|
|
642
|
+
fields,
|
|
643
|
+
row,
|
|
644
|
+
reorderable,
|
|
645
|
+
onPatch,
|
|
646
|
+
onRemove,
|
|
584
647
|
}: {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
588
657
|
}) {
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
function add() {
|
|
599
|
-
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,
|
|
600
666
|
}
|
|
601
667
|
|
|
602
668
|
return (
|
|
603
|
-
<div
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
/>
|
|
636
709
|
))}
|
|
637
710
|
</div>
|
|
638
|
-
<button
|
|
639
|
-
type="button"
|
|
640
|
-
onClick={add}
|
|
641
|
-
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"
|
|
642
|
-
>
|
|
643
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
644
|
-
Add stat
|
|
645
|
-
</button>
|
|
646
711
|
</div>
|
|
647
712
|
)
|
|
648
713
|
}
|
|
@@ -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'
|