@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
|
@@ -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(
|
|
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
|
-
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|