@actuate-media/cms-admin 0.39.0 → 0.40.1

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 (29) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +22 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/general-settings.render.test.js +23 -2
  5. package/dist/__tests__/views/general-settings.render.test.js.map +1 -1
  6. package/dist/actuate-admin.css +1 -1
  7. package/dist/lib/page-editor-service.js +1 -1
  8. package/dist/lib/page-editor-service.js.map +1 -1
  9. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  10. package/dist/views/page-editor/SectionInspector.js +61 -28
  11. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  12. package/dist/views/page-editor/section-types.d.ts +2 -2
  13. package/dist/views/page-editor/section-types.d.ts.map +1 -1
  14. package/dist/views/page-editor/section-types.js +1 -1
  15. package/dist/views/page-editor/section-types.js.map +1 -1
  16. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -1
  17. package/dist/views/settings/SeoRobotsDefaultsCard.js +2 -1
  18. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -1
  19. package/dist/views/settings/components.d.ts.map +1 -1
  20. package/dist/views/settings/components.js +1 -1
  21. package/dist/views/settings/components.js.map +1 -1
  22. package/package.json +2 -2
  23. package/src/AdminRoot.tsx +23 -4
  24. package/src/__tests__/views/general-settings.render.test.tsx +33 -2
  25. package/src/lib/page-editor-service.ts +1 -1
  26. package/src/views/page-editor/SectionInspector.tsx +190 -125
  27. package/src/views/page-editor/section-types.ts +5 -0
  28. package/src/views/settings/SeoRobotsDefaultsCard.tsx +9 -0
  29. package/src/views/settings/components.tsx +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 { 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
@@ -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
- sections.customTypes
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
- if (field.type === 'stats') {
295
- return <StatsField label={field.label} value={value} onChange={onChange} />
296
- }
297
-
298
- if (field.type === 'gallery') {
299
- 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
+ )
300
327
  }
301
328
 
302
329
  if (field.type === 'boolean') {
@@ -496,153 +523,191 @@ function RelationshipField({
496
523
  )
497
524
  }
498
525
 
499
- interface ImageRow {
500
- url?: string
501
- alt?: string
502
- caption?: string
503
- }
504
-
505
- 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,
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: 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}`
515
554
 
516
- function update(index: number, patch: Partial<ImageRow>) {
517
- onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
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
- onChange(rows.filter((_, i) => i !== index))
567
+ commit(rows.filter((_, i) => i !== index))
521
568
  }
522
569
  function add() {
523
- onChange([...rows, { url: '', alt: '', caption: '' }])
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
- <div className="space-y-3">
530
- {rows.map((row, i) => (
531
- <div key={i} className="border-border rounded-md border p-2">
532
- <div className="mb-2 flex items-center justify-between">
533
- <span className="text-muted-foreground text-xs">Image {i + 1}</span>
534
- <button
535
- type="button"
536
- onClick={() => remove(i)}
537
- aria-label={`Remove image ${i + 1}`}
538
- className="text-muted-foreground hover:text-destructive rounded p-0.5"
539
- >
540
- <X className="h-3.5 w-3.5" aria-hidden />
541
- </button>
542
- </div>
543
- <div className="space-y-2">
544
- <input
545
- className={INPUT}
546
- placeholder="Image URL (https://…)"
547
- value={row.url ?? ''}
548
- onChange={(e) => update(i, { url: e.target.value })}
549
- aria-label={`Image ${i + 1} URL`}
550
- />
551
- <input
552
- className={INPUT}
553
- placeholder="Alt text (for accessibility)"
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
- </div>
560
- ))}
561
- </div>
618
+ </SortableContext>
619
+ </DndContext>
620
+ )}
562
621
  <button
563
622
  type="button"
564
623
  onClick={add}
565
- 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"
566
626
  >
567
627
  <Plus className="h-3.5 w-3.5" aria-hidden />
568
- Add image
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
- interface StatRow {
575
- value?: string
576
- label?: string
577
- description?: string
578
- }
579
-
580
- function StatsField({
581
- label,
582
- value,
583
- onChange,
637
+ function SortableRepeaterRow({
638
+ id,
639
+ index,
640
+ fieldKey,
641
+ rowLabel,
642
+ fields,
643
+ row,
644
+ reorderable,
645
+ onPatch,
646
+ onRemove,
584
647
  }: {
585
- label: string
586
- value: unknown
587
- 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
588
657
  }) {
589
- const rows: StatRow[] = Array.isArray(value) ? (value as StatRow[]) : []
590
-
591
- function update(index: number, patch: Partial<StatRow>) {
592
- const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
593
- onChange(next)
594
- }
595
- function remove(index: number) {
596
- onChange(rows.filter((_, i) => i !== index))
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
- <span className={LABEL}>{label}</span>
605
- <div className="space-y-3">
606
- {rows.map((row, i) => (
607
- <div key={i} className="border-border rounded-md border p-2">
608
- <div className="mb-2 flex items-center justify-between">
609
- <span className="text-muted-foreground text-xs">Stat {i + 1}</span>
610
- <button
611
- type="button"
612
- onClick={() => remove(i)}
613
- aria-label={`Remove stat ${i + 1}`}
614
- className="text-muted-foreground hover:text-destructive rounded p-0.5"
615
- >
616
- <X className="h-3.5 w-3.5" aria-hidden />
617
- </button>
618
- </div>
619
- <div className="space-y-2">
620
- <input
621
- className={INPUT}
622
- placeholder="Value (e.g. 99.99%)"
623
- value={row.value ?? ''}
624
- onChange={(e) => update(i, { value: e.target.value })}
625
- aria-label={`Stat ${i + 1} value`}
626
- />
627
- <input
628
- className={INPUT}
629
- placeholder="Label (e.g. Uptime)"
630
- value={row.label ?? ''}
631
- onChange={(e) => update(i, { label: e.target.value })}
632
- aria-label={`Stat ${i + 1} label`}
633
- />
634
- </div>
635
- </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
+ />
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'
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useState } from 'react'
4
+ import { Info } from 'lucide-react'
4
5
  import { ConfirmDangerousSettingDialog, SettingsCard, SettingsToggleRow } from './components.js'
5
6
  import type { GeneralSettingsForm, SiteIndexStatus } from './useGeneralSettings.js'
6
7
 
@@ -90,6 +91,14 @@ export function SeoRobotsDefaultsCard({
90
91
  disabled={!canEdit}
91
92
  onChange={(v) => requestToggle('noIndexNonProduction', v)}
92
93
  />
94
+ {isProduction && (
95
+ <p className="text-muted-foreground flex items-start gap-2 text-sm">
96
+ <Info size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
97
+ This deployment is running in the production environment, so this setting does not
98
+ change pages served here — it forces noindex only on preview, staging, and development
99
+ deployments. To block search engines from this site, use Default No Index.
100
+ </p>
101
+ )}
93
102
  </div>
94
103
 
95
104
  <div className="border-border mt-4 space-y-2 border-t pt-4">
@@ -246,6 +246,11 @@ export function SettingsSaveBar({
246
246
  <Check size={16} aria-hidden="true" /> Saved
247
247
  </span>
248
248
  )}
249
+ {dirty && !saving && (
250
+ <span className="text-muted-foreground text-sm" aria-live="polite">
251
+ Unsaved changes
252
+ </span>
253
+ )}
249
254
  <button
250
255
  type="button"
251
256
  onClick={onReset}