@actuate-media/cms-admin 0.38.0 → 0.39.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 (91) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +8 -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__/no-server-barrel-imports.test.d.ts +2 -0
  11. package/dist/__tests__/no-server-barrel-imports.test.d.ts.map +1 -0
  12. package/dist/__tests__/no-server-barrel-imports.test.js +50 -0
  13. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -0
  14. package/dist/__tests__/router/navigate-pushstate.test.d.ts +2 -0
  15. package/dist/__tests__/router/navigate-pushstate.test.d.ts.map +1 -0
  16. package/dist/__tests__/router/navigate-pushstate.test.js +37 -0
  17. package/dist/__tests__/router/navigate-pushstate.test.js.map +1 -0
  18. package/dist/__tests__/views/appearance-settings.render.test.js +34 -0
  19. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -1
  20. package/dist/components/Breadcrumbs.d.ts +1 -0
  21. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  22. package/dist/components/Breadcrumbs.js +10 -2
  23. package/dist/components/Breadcrumbs.js.map +1 -1
  24. package/dist/components/MediaPickerModal.d.ts +9 -1
  25. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  26. package/dist/components/MediaPickerModal.js +11 -5
  27. package/dist/components/MediaPickerModal.js.map +1 -1
  28. package/dist/hooks/useBuilderState.js +1 -1
  29. package/dist/hooks/useBuilderState.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts.map +1 -1
  31. package/dist/lib/page-editor-service.js +10 -3
  32. package/dist/lib/page-editor-service.js.map +1 -1
  33. package/dist/router/index.d.ts.map +1 -1
  34. package/dist/router/index.js +6 -1
  35. package/dist/router/index.js.map +1 -1
  36. package/dist/views/page-builder/BlockEditor.js +1 -1
  37. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  38. package/dist/views/page-builder/BlockPicker.js +1 -1
  39. package/dist/views/page-builder/BlockPicker.js.map +1 -1
  40. package/dist/views/page-builder/DesignScore.js +1 -1
  41. package/dist/views/page-builder/DesignScore.js.map +1 -1
  42. package/dist/views/page-builder/SEOPanel.js +1 -1
  43. package/dist/views/page-builder/SEOPanel.js.map +1 -1
  44. package/dist/views/page-builder/SavedSections.d.ts.map +1 -1
  45. package/dist/views/page-builder/SavedSections.js +1 -1
  46. package/dist/views/page-builder/SavedSections.js.map +1 -1
  47. package/dist/views/page-builder/TemplatePicker.js +1 -1
  48. package/dist/views/page-builder/TemplatePicker.js.map +1 -1
  49. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  50. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  51. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  52. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  53. package/dist/views/page-editor/SectionInspector.js +6 -1
  54. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  55. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  56. package/dist/views/page-editor/SectionsPanel.js +7 -4
  57. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  58. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  59. package/dist/views/page-editor/sections/SectionRenderer.js +14 -1
  60. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  61. package/dist/views/settings/BrandingCard.d.ts.map +1 -1
  62. package/dist/views/settings/BrandingCard.js +15 -4
  63. package/dist/views/settings/BrandingCard.js.map +1 -1
  64. package/dist/views/settings/useAppearanceSettings.d.ts +10 -0
  65. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  66. package/dist/views/settings/useAppearanceSettings.js +24 -0
  67. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/AdminRoot.tsx +12 -0
  70. package/src/__tests__/components/breadcrumbs-is-id.test.ts +32 -0
  71. package/src/__tests__/lib/page-editor-service-api.test.ts +43 -0
  72. package/src/__tests__/no-server-barrel-imports.test.ts +58 -0
  73. package/src/__tests__/router/navigate-pushstate.test.ts +46 -0
  74. package/src/__tests__/views/appearance-settings.render.test.tsx +45 -0
  75. package/src/components/Breadcrumbs.tsx +11 -2
  76. package/src/components/MediaPickerModal.tsx +68 -39
  77. package/src/hooks/useBuilderState.ts +1 -1
  78. package/src/lib/page-editor-service.ts +10 -3
  79. package/src/router/index.ts +6 -1
  80. package/src/views/page-builder/BlockEditor.tsx +1 -1
  81. package/src/views/page-builder/BlockPicker.tsx +1 -1
  82. package/src/views/page-builder/DesignScore.tsx +1 -1
  83. package/src/views/page-builder/SEOPanel.tsx +1 -1
  84. package/src/views/page-builder/SavedSections.tsx +6 -1
  85. package/src/views/page-builder/TemplatePicker.tsx +1 -1
  86. package/src/views/page-editor/PageSectionEditor.tsx +24 -1
  87. package/src/views/page-editor/SectionInspector.tsx +50 -1
  88. package/src/views/page-editor/SectionsPanel.tsx +54 -38
  89. package/src/views/page-editor/sections/SectionRenderer.tsx +25 -1
  90. package/src/views/settings/BrandingCard.tsx +37 -2
  91. package/src/views/settings/useAppearanceSettings.ts +39 -0
@@ -1,7 +1,7 @@
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, Lock, Plus, X } from 'lucide-react'
5
5
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
6
6
  import type { PageSection } from '../../lib/page-editor-service.js'
7
7
  import type { SectionSettings } from './section-types.js'
@@ -41,6 +41,55 @@ export function SectionInspector({
41
41
  return () => document.removeEventListener('keydown', onKey)
42
42
  }, [onClose])
43
43
 
44
+ // Unmanaged sections (no registered type) are read-only: show why instead of
45
+ // editable controls, so the externally-managed content is never altered here.
46
+ if (section.unmanaged) {
47
+ return (
48
+ <aside
49
+ role="dialog"
50
+ aria-label={`${section.name} (read-only)`}
51
+ className="bg-card border-border flex h-full w-80 shrink-0 flex-col border-l"
52
+ >
53
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
54
+ <div className="min-w-0">
55
+ <h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
56
+ <p className="text-muted-foreground text-xs">Managed outside the editor</p>
57
+ </div>
58
+ <button
59
+ type="button"
60
+ onClick={onClose}
61
+ aria-label="Close inspector"
62
+ className="text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1"
63
+ >
64
+ <X className="h-4 w-4" aria-hidden />
65
+ </button>
66
+ </div>
67
+ <div className="flex-1 space-y-3 overflow-y-auto p-4">
68
+ <div className="border-border bg-muted text-muted-foreground space-y-2 rounded-md border p-3 text-sm">
69
+ <p className="text-foreground flex items-center gap-2 font-medium">
70
+ <Lock size={14} aria-hidden /> Read-only section
71
+ </p>
72
+ <p>
73
+ The section type{' '}
74
+ <code className="bg-background rounded px-1 py-0.5 text-xs">
75
+ {section.sectionType}
76
+ </code>{' '}
77
+ isn&apos;t registered in this CMS, so it can&apos;t be edited here. It is preserved
78
+ exactly as-is on every save.
79
+ </p>
80
+ <p>
81
+ 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.
86
+ </p>
87
+ </div>
88
+ </div>
89
+ </aside>
90
+ )
91
+ }
92
+
44
93
  return (
45
94
  <aside
46
95
  role="dialog"
@@ -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
  )
@@ -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
@@ -5,14 +5,20 @@ import {
5
5
  ChevronDown,
6
6
  ChevronRight,
7
7
  ImageIcon,
8
+ Library,
8
9
  Loader2,
9
10
  Trash2,
10
11
  Upload,
11
12
  } from 'lucide-react'
12
- import { useRef } from 'react'
13
+ import { useRef, useState } from 'react'
13
14
 
15
+ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
14
16
  import { SettingsCard, SettingsControlRow, SettingsSubsection } from './components.js'
15
- import type { BrandAssetField, UseAppearanceSettings } from './useAppearanceSettings.js'
17
+ import type {
18
+ BrandAssetField,
19
+ SelectedBrandAsset,
20
+ UseAppearanceSettings,
21
+ } from './useAppearanceSettings.js'
16
22
 
17
23
  const LOGO_ACCEPT = 'image/svg+xml,image/png'
18
24
 
@@ -33,6 +39,7 @@ function LogoField({
33
39
  canEdit,
34
40
  warning,
35
41
  onUpload,
42
+ onSelectFromLibrary,
36
43
  onRemove,
37
44
  }: {
38
45
  label: string
@@ -47,9 +54,11 @@ function LogoField({
47
54
  canEdit: boolean
48
55
  warning?: string
49
56
  onUpload: (field: BrandAssetField, file: File) => void
57
+ onSelectFromLibrary: (field: BrandAssetField, item: SelectedBrandAsset) => void
50
58
  onRemove: (field: BrandAssetField) => void
51
59
  }) {
52
60
  const inputRef = useRef<HTMLInputElement>(null)
61
+ const [pickerOpen, setPickerOpen] = useState(false)
53
62
  const assetId = brand[field]
54
63
  const asset = assetId ? assets[assetId] : undefined
55
64
  const uploading = uploadingField === field
@@ -102,6 +111,15 @@ function LogoField({
102
111
  <Trash2 size={14} aria-hidden="true" /> Remove
103
112
  </button>
104
113
  )}
114
+ <button
115
+ type="button"
116
+ onClick={() => setPickerOpen(true)}
117
+ disabled={!canEdit || uploading}
118
+ aria-label={`Choose ${label.toLowerCase()} from media library`}
119
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50"
120
+ >
121
+ <Library size={14} aria-hidden="true" /> Media library
122
+ </button>
105
123
  <button
106
124
  type="button"
107
125
  onClick={() => inputRef.current?.click()}
@@ -116,6 +134,22 @@ function LogoField({
116
134
  )}
117
135
  {asset ? 'Replace' : 'Upload'}
118
136
  </button>
137
+ <MediaPickerModal
138
+ open={pickerOpen}
139
+ onClose={() => setPickerOpen(false)}
140
+ accept={accept}
141
+ libraryOnly
142
+ onSelect={() => {}}
143
+ onSelectItem={(item) => {
144
+ onSelectFromLibrary(field, {
145
+ id: item.id,
146
+ url: item.url,
147
+ mimeType: item.mimeType,
148
+ width: item.width,
149
+ height: item.height,
150
+ })
151
+ }}
152
+ />
119
153
  </>
120
154
  )
121
155
 
@@ -197,6 +231,7 @@ export function BrandingCard({ s, canEdit }: { s: UseAppearanceSettings; canEdit
197
231
  uploadingField: s.uploadingField,
198
232
  canEdit,
199
233
  onUpload: s.uploadAsset,
234
+ onSelectFromLibrary: s.selectAsset,
200
235
  onRemove: s.removeAsset,
201
236
  }
202
237
 
@@ -75,6 +75,15 @@ export interface AssetPreview {
75
75
  shape: LogoShape
76
76
  }
77
77
 
78
+ /** An existing media-library asset chosen for a brand field via the picker. */
79
+ export interface SelectedBrandAsset {
80
+ id: string
81
+ url: string
82
+ mimeType: string
83
+ width?: number
84
+ height?: number
85
+ }
86
+
78
87
  const LOGO_MIME = ['image/svg+xml', 'image/png']
79
88
  const FAVICON_MIME = ['image/svg+xml', 'image/png']
80
89
  const MAX_BYTES = 1024 * 1024 // 1 MB
@@ -103,6 +112,8 @@ export interface UseAppearanceSettings {
103
112
  setBrandingMode: (v: AdminBrandingMode) => void
104
113
  setAltText: (v: string) => void
105
114
  uploadAsset: (field: BrandAssetField, file: File) => Promise<void>
115
+ /** Point a brand field at an EXISTING media asset (no re-upload, no duplicate). */
116
+ selectAsset: (field: BrandAssetField, item: SelectedBrandAsset) => void
106
117
  removeAsset: (field: BrandAssetField) => void
107
118
  save: () => Promise<void>
108
119
  reset: () => void
@@ -375,6 +386,33 @@ export function useAppearanceSettings(
375
386
  [appearance, brand, assets, update],
376
387
  )
377
388
 
389
+ const selectAsset = useCallback(
390
+ (field: BrandAssetField, item: SelectedBrandAsset) => {
391
+ // Same allow-list as upload: brand assets must be SVG or PNG, regardless
392
+ // of what else lives in the media library.
393
+ const isFavicon = field === 'faviconSourceAssetId'
394
+ const allowed = isFavicon ? FAVICON_MIME : LOGO_MIME
395
+ if (!allowed.includes(item.mimeType)) {
396
+ setSaveError(`Unsupported file type. Brand assets must be ${allowed.join(' or ')}.`)
397
+ return
398
+ }
399
+ setSaveError(null)
400
+ const nextAssets: Record<string, AssetPreview> = {
401
+ ...assets,
402
+ [item.id]: {
403
+ id: item.id,
404
+ url: item.url,
405
+ mimeType: item.mimeType,
406
+ width: item.width,
407
+ height: item.height,
408
+ shape: classifyLogoShape(item.width, item.height),
409
+ },
410
+ }
411
+ update(appearance, { ...brand, [field]: item.id }, nextAssets)
412
+ },
413
+ [appearance, brand, assets, update],
414
+ )
415
+
378
416
  const removeAsset = useCallback(
379
417
  (field: BrandAssetField) => {
380
418
  const next = { ...brand }
@@ -505,6 +543,7 @@ export function useAppearanceSettings(
505
543
  setBrandingMode,
506
544
  setAltText,
507
545
  uploadAsset,
546
+ selectAsset,
508
547
  removeAsset,
509
548
  save,
510
549
  reset,