@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
@@ -10,6 +10,8 @@ interface MediaItem {
10
10
  id: string
11
11
  filename: string
12
12
  storageKey: string
13
+ /** Normalized public URL from the media list endpoint (preferred for display). */
14
+ url?: string
13
15
  mimeType: string
14
16
  fileSize: number
15
17
  width?: number
@@ -20,6 +22,8 @@ interface MediaItem {
20
22
  export interface SelectedMediaItem {
21
23
  id: string
22
24
  storageKey: string
25
+ /** Normalized public URL (falls back to storageKey when absent). */
26
+ url: string
23
27
  filename: string
24
28
  mimeType: string
25
29
  width?: number
@@ -36,6 +40,12 @@ export interface MediaPickerModalProps {
36
40
  */
37
41
  onSelectItem?: (item: SelectedMediaItem) => void
38
42
  accept?: string
43
+ /**
44
+ * Hide the "Upload New" tab and show only the existing library. Used where a
45
+ * caller has its own upload path (e.g. brand assets, which upload with
46
+ * format-preserving options) but still wants to reuse library assets.
47
+ */
48
+ libraryOnly?: boolean
39
49
  }
40
50
 
41
51
  export function MediaPickerModal({
@@ -44,6 +54,7 @@ export function MediaPickerModal({
44
54
  onSelect,
45
55
  onSelectItem,
46
56
  accept,
57
+ libraryOnly = false,
47
58
  }: MediaPickerModalProps) {
48
59
  const [tab, setTab] = useState<'library' | 'upload'>('library')
49
60
  const [search, setSearch] = useState('')
@@ -74,11 +85,12 @@ export function MediaPickerModal({
74
85
  toast.error(res.error)
75
86
  } else if (res.data) {
76
87
  const mediaItem = res.data as MediaItem
77
- const url = mediaItem.storageKey ?? ''
88
+ const url = mediaItem.url || mediaItem.storageKey || ''
78
89
  toast.success(`Uploaded ${file.name}`)
79
90
  onSelectItem?.({
80
91
  id: mediaItem.id,
81
92
  storageKey: mediaItem.storageKey,
93
+ url,
82
94
  filename: mediaItem.filename ?? file.name,
83
95
  mimeType: mediaItem.mimeType ?? file.type,
84
96
  width: mediaItem.width,
@@ -93,15 +105,17 @@ export function MediaPickerModal({
93
105
  }
94
106
 
95
107
  function handleSelectItem(item: MediaItem) {
108
+ const url = item.url || item.storageKey || ''
96
109
  onSelectItem?.({
97
110
  id: item.id,
98
111
  storageKey: item.storageKey,
112
+ url,
99
113
  filename: item.filename,
100
114
  mimeType: item.mimeType,
101
115
  width: item.width,
102
116
  height: item.height,
103
117
  })
104
- onSelect(item.storageKey, item.filename)
118
+ onSelect(url, item.filename)
105
119
  onClose()
106
120
  }
107
121
 
@@ -121,31 +135,33 @@ export function MediaPickerModal({
121
135
  </button>
122
136
  </div>
123
137
 
124
- <div className="flex border-b border-gray-200">
125
- <button
126
- onClick={() => setTab('library')}
127
- className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
128
- tab === 'library'
129
- ? 'border-b-2 border-blue-600 text-blue-600'
130
- : 'text-gray-500 hover:text-gray-700'
131
- }`}
132
- >
133
- Media Library
134
- </button>
135
- <button
136
- onClick={() => setTab('upload')}
137
- className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
138
- tab === 'upload'
139
- ? 'border-b-2 border-blue-600 text-blue-600'
140
- : 'text-gray-500 hover:text-gray-700'
141
- }`}
142
- >
143
- Upload New
144
- </button>
145
- </div>
138
+ {!libraryOnly && (
139
+ <div className="flex border-b border-gray-200">
140
+ <button
141
+ onClick={() => setTab('library')}
142
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
143
+ tab === 'library'
144
+ ? 'border-b-2 border-blue-600 text-blue-600'
145
+ : 'text-gray-500 hover:text-gray-700'
146
+ }`}
147
+ >
148
+ Media Library
149
+ </button>
150
+ <button
151
+ onClick={() => setTab('upload')}
152
+ className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
153
+ tab === 'upload'
154
+ ? 'border-b-2 border-blue-600 text-blue-600'
155
+ : 'text-gray-500 hover:text-gray-700'
156
+ }`}
157
+ >
158
+ Upload New
159
+ </button>
160
+ </div>
161
+ )}
146
162
 
147
163
  <div className="flex-1 overflow-y-auto p-4">
148
- {tab === 'library' ? (
164
+ {libraryOnly || tab === 'library' ? (
149
165
  <>
150
166
  <div className="relative mb-4">
151
167
  <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
@@ -168,20 +184,33 @@ export function MediaPickerModal({
168
184
  </div>
169
185
  ) : (
170
186
  <div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
171
- {imageItems.map((item: MediaItem) => (
172
- <button
173
- key={item.id}
174
- onClick={() => handleSelectItem(item)}
175
- className="group relative aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-100 transition-colors hover:border-blue-500"
176
- >
177
- <div className="flex h-full w-full items-center justify-center">
178
- <ImageIcon className="h-8 w-8 text-gray-300" />
179
- </div>
180
- <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
181
- <p className="truncate text-xs text-white">{item.filename}</p>
182
- </div>
183
- </button>
184
- ))}
187
+ {imageItems.map((item: MediaItem) => {
188
+ const src = item.url || item.storageKey
189
+ return (
190
+ <button
191
+ key={item.id}
192
+ onClick={() => handleSelectItem(item)}
193
+ title={item.filename}
194
+ className="group relative aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-100 transition-colors hover:border-blue-500"
195
+ >
196
+ {src ? (
197
+ <img
198
+ src={src}
199
+ alt={item.filename}
200
+ loading="lazy"
201
+ className="h-full w-full object-contain"
202
+ />
203
+ ) : (
204
+ <div className="flex h-full w-full items-center justify-center">
205
+ <ImageIcon className="h-8 w-8 text-gray-300" />
206
+ </div>
207
+ )}
208
+ <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
209
+ <p className="truncate text-xs text-white">{item.filename}</p>
210
+ </div>
211
+ </button>
212
+ )
213
+ })}
185
214
  </div>
186
215
  )}
187
216
  </>
@@ -112,7 +112,9 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
112
112
  errors.push('Add at least one visible section before publishing.')
113
113
  }
114
114
 
115
- for (const section of visible) {
115
+ // Unmanaged (externally-managed) sections have no registered field schema,
116
+ // so they can't be content-validated here and must not block publish.
117
+ for (const section of visible.filter((s) => !s.unmanaged)) {
116
118
  const def = getSectionType(section.sectionType)
117
119
  const label = section.name || def?.name || 'Section'
118
120
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -216,7 +218,9 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
216
218
  slug,
217
219
  path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
218
220
  status: doc.status ?? 'DRAFT',
219
- sections: coerceSectionsCore(data.sections),
221
+ // Preserve unknown (externally-managed) sections so the editor can show
222
+ // them read-only and round-trip them on save instead of dropping them.
223
+ sections: coerceSectionsCore(data.sections, { onUnknown: 'preserve' }),
220
224
  seoTitle: typeof data.metaTitle === 'string' ? data.metaTitle : '',
221
225
  seoDescription: typeof data.metaDescription === 'string' ? data.metaDescription : '',
222
226
  publishedAt: doc.publishedAt ?? null,
@@ -252,7 +256,10 @@ function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string
252
256
  title: page.title,
253
257
  slug: page.slug,
254
258
  path: page.path,
255
- sections: page.sections,
259
+ // Unmanaged sections are part of `page.sections`, so they round-trip on
260
+ // every save (no data loss). Strip the derived `unmanaged` marker — it is
261
+ // recomputed from the registry on read, so persisting it would only stale.
262
+ sections: page.sections.map(({ unmanaged: _unmanaged, ...rest }) => rest),
256
263
  metaTitle: page.seoTitle,
257
264
  metaDescription: page.seoDescription,
258
265
  ...extra,
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState } from 'react'
4
- import { AlertTriangle, Blocks, Loader2 } from 'lucide-react'
4
+ import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
@@ -392,6 +392,12 @@ export function PageSectionEditor({
392
392
  const readOnly = page.builderOwned
393
393
  const effectiveCanEdit = canEdit && !readOnly
394
394
 
395
+ // Sections whose type has no registered definition (seed/code-owned, e.g.
396
+ // `opal-*`). They are preserved verbatim on save but can't be edited here.
397
+ const unmanagedTypes = Array.from(
398
+ new Set(page.sections.filter((s) => s.unmanaged).map((s) => s.sectionType)),
399
+ )
400
+
395
401
  return (
396
402
  <ErrorBoundary>
397
403
  <div className="bg-background flex h-full flex-col overflow-hidden">
@@ -436,6 +442,23 @@ export function PageSectionEditor({
436
442
  </div>
437
443
  )}
438
444
 
445
+ {unmanagedTypes.length > 0 && (
446
+ <div
447
+ role="status"
448
+ className="border-border bg-muted/50 flex items-start gap-3 border-b px-4 py-2.5"
449
+ >
450
+ <Lock className="text-muted-foreground mt-0.5 shrink-0" size={16} aria-hidden />
451
+ <p className="text-muted-foreground flex-1 text-sm">
452
+ This page has{' '}
453
+ <span className="text-foreground font-medium">
454
+ {unmanagedTypes.length} section type{unmanagedTypes.length > 1 ? 's' : ''}
455
+ </span>{' '}
456
+ managed outside the editor ({unmanagedTypes.join(', ')}). They are preserved on save
457
+ but edited in your seed script or code, not here.
458
+ </p>
459
+ </div>
460
+ )}
461
+
439
462
  {validation && (
440
463
  <ValidationSummary
441
464
  errors={validation.errors}