@actuate-media/cms-admin 0.7.3 → 0.8.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 +87 -36
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +33 -4
- package/dist/lib/api.js.map +1 -1
- package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
- package/dist/views/page-builder/BlockEditor.js +94 -3
- package/dist/views/page-builder/BlockEditor.js.map +1 -1
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +21 -1
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +94 -41
- package/src/lib/api.ts +31 -3
- package/src/views/page-builder/BlockEditor.tsx +209 -30
- package/src/views/page-builder/PageBuilder.tsx +18 -1
|
@@ -6,6 +6,8 @@ import * as SwitchPrimitive from '@radix-ui/react-switch';
|
|
|
6
6
|
import type { BlockNode, BlockTypeDefinition, FieldDefinition } from '@actuate-media/cms-core';
|
|
7
7
|
import { BlockCatalog } from '@actuate-media/cms-core';
|
|
8
8
|
import { AIBlockAssist } from './AIBlockAssist.js';
|
|
9
|
+
import { TipTapEditor } from '../../components/TipTapEditor.js';
|
|
10
|
+
import { MediaPickerModal } from '../../components/MediaPickerModal.js';
|
|
9
11
|
|
|
10
12
|
export interface BlockEditorProps {
|
|
11
13
|
node: BlockNode;
|
|
@@ -180,13 +182,13 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
|
|
|
180
182
|
return (
|
|
181
183
|
<div>
|
|
182
184
|
<label className={LABEL_CLASS}>{label}</label>
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
<div className="rounded-md border border-input overflow-hidden bg-background">
|
|
186
|
+
<TipTapEditor
|
|
187
|
+
content={(value as string) ?? ''}
|
|
188
|
+
onChange={(html) => onChange(html)}
|
|
189
|
+
placeholder={definition.admin?.placeholder}
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
190
192
|
</div>
|
|
191
193
|
);
|
|
192
194
|
|
|
@@ -236,26 +238,7 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
|
|
|
236
238
|
);
|
|
237
239
|
|
|
238
240
|
case 'media':
|
|
239
|
-
return
|
|
240
|
-
<div>
|
|
241
|
-
<label className={LABEL_CLASS}>{label}</label>
|
|
242
|
-
<div className="flex gap-2">
|
|
243
|
-
<input
|
|
244
|
-
type="text"
|
|
245
|
-
value={(value as string) ?? ''}
|
|
246
|
-
onChange={(e) => onChange(e.target.value)}
|
|
247
|
-
placeholder="Select media..."
|
|
248
|
-
className={`${INPUT_CLASS} flex-1`}
|
|
249
|
-
/>
|
|
250
|
-
<button
|
|
251
|
-
type="button"
|
|
252
|
-
className="px-3 py-2 text-xs font-medium bg-accent text-foreground border border-input rounded-md hover:bg-accent/80 transition-colors"
|
|
253
|
-
>
|
|
254
|
-
Browse
|
|
255
|
-
</button>
|
|
256
|
-
</div>
|
|
257
|
-
</div>
|
|
258
|
-
);
|
|
241
|
+
return <MediaFieldRenderer label={label} value={value} onChange={onChange} />;
|
|
259
242
|
|
|
260
243
|
case 'select':
|
|
261
244
|
return (
|
|
@@ -277,12 +260,41 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
|
|
|
277
260
|
);
|
|
278
261
|
|
|
279
262
|
case 'array':
|
|
263
|
+
return <ArrayFieldRenderer label={label} value={value} onChange={onChange} />;
|
|
264
|
+
|
|
265
|
+
case 'json':
|
|
266
|
+
return <JsonFieldRenderer label={label} value={value} onChange={onChange} />;
|
|
267
|
+
|
|
268
|
+
case 'date':
|
|
280
269
|
return (
|
|
281
270
|
<div>
|
|
282
271
|
<label className={LABEL_CLASS}>{label}</label>
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
272
|
+
<input
|
|
273
|
+
type="date"
|
|
274
|
+
value={typeof value === 'string' && value ? value.slice(0, 10) : ''}
|
|
275
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
276
|
+
className={INPUT_CLASS}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
case 'slug':
|
|
282
|
+
return (
|
|
283
|
+
<div>
|
|
284
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
285
|
+
<input
|
|
286
|
+
type="text"
|
|
287
|
+
value={(value as string) ?? ''}
|
|
288
|
+
onChange={(e) => {
|
|
289
|
+
const slug = e.target.value
|
|
290
|
+
.toLowerCase()
|
|
291
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
292
|
+
.replace(/^-+|-+$/g, '');
|
|
293
|
+
onChange(slug);
|
|
294
|
+
}}
|
|
295
|
+
placeholder={definition.admin?.placeholder ?? 'page-slug'}
|
|
296
|
+
className={INPUT_CLASS}
|
|
297
|
+
/>
|
|
286
298
|
</div>
|
|
287
299
|
);
|
|
288
300
|
|
|
@@ -350,3 +362,170 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
|
|
|
350
362
|
);
|
|
351
363
|
}
|
|
352
364
|
}
|
|
365
|
+
|
|
366
|
+
interface SimpleFieldProps {
|
|
367
|
+
label: string;
|
|
368
|
+
value: unknown;
|
|
369
|
+
onChange: (value: unknown) => void;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Block-level media picker. Wires the cross-cutting MediaPickerModal so
|
|
374
|
+
* custom blocks declared with `type: 'media'` actually get a working
|
|
375
|
+
* browse-and-select flow instead of a free-text input.
|
|
376
|
+
*/
|
|
377
|
+
function MediaFieldRenderer({ label, value, onChange }: SimpleFieldProps) {
|
|
378
|
+
const [open, setOpen] = useState(false);
|
|
379
|
+
const url = typeof value === 'string'
|
|
380
|
+
? value
|
|
381
|
+
: (value && typeof value === 'object' && 'url' in (value as any))
|
|
382
|
+
? String((value as any).url)
|
|
383
|
+
: '';
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<div>
|
|
387
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
388
|
+
<div className="flex gap-2">
|
|
389
|
+
<input
|
|
390
|
+
type="text"
|
|
391
|
+
value={url}
|
|
392
|
+
onChange={(e) => onChange(e.target.value)}
|
|
393
|
+
placeholder="https://..."
|
|
394
|
+
className={`${INPUT_CLASS} flex-1`}
|
|
395
|
+
/>
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
onClick={() => setOpen(true)}
|
|
399
|
+
className="px-3 py-2 text-xs font-medium bg-accent text-foreground border border-input rounded-md hover:bg-accent/80 transition-colors"
|
|
400
|
+
>
|
|
401
|
+
Browse
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
{url && /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(url) && (
|
|
405
|
+
<div className="mt-2 rounded-md border border-border overflow-hidden bg-muted">
|
|
406
|
+
{/* Plain img is fine here; admin only and we don't have next/image in scope. */}
|
|
407
|
+
<img src={url} alt="" className="w-full h-32 object-contain bg-checkered" />
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
<MediaPickerModal
|
|
411
|
+
open={open}
|
|
412
|
+
onClose={() => setOpen(false)}
|
|
413
|
+
onSelect={(selectedUrl) => {
|
|
414
|
+
onChange(selectedUrl);
|
|
415
|
+
setOpen(false);
|
|
416
|
+
}}
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* JSON editor for free-form object/array fields. The textarea is the
|
|
424
|
+
* source of truth while the user is typing — we only commit a parsed
|
|
425
|
+
* value back upstream once the JSON is well-formed, otherwise the user
|
|
426
|
+
* sees a validation error inline. This prevents typing-induced state
|
|
427
|
+
* loss and keeps the UI usable for arrays of objects (a common shape
|
|
428
|
+
* for custom blocks).
|
|
429
|
+
*/
|
|
430
|
+
function JsonFieldRenderer({ label, value, onChange }: SimpleFieldProps) {
|
|
431
|
+
const initial = useMemo(() => {
|
|
432
|
+
if (value === undefined || value === null) return '';
|
|
433
|
+
try {
|
|
434
|
+
return JSON.stringify(value, null, 2);
|
|
435
|
+
} catch {
|
|
436
|
+
return '';
|
|
437
|
+
}
|
|
438
|
+
}, [value]);
|
|
439
|
+
const [draft, setDraft] = useState(initial);
|
|
440
|
+
const [error, setError] = useState<string | null>(null);
|
|
441
|
+
|
|
442
|
+
const handleChange = (next: string) => {
|
|
443
|
+
setDraft(next);
|
|
444
|
+
if (next.trim() === '') {
|
|
445
|
+
setError(null);
|
|
446
|
+
onChange(undefined);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
onChange(JSON.parse(next));
|
|
451
|
+
setError(null);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
setError(err instanceof Error ? err.message : 'Invalid JSON');
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<div>
|
|
459
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
460
|
+
<textarea
|
|
461
|
+
value={draft}
|
|
462
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
463
|
+
rows={6}
|
|
464
|
+
spellCheck={false}
|
|
465
|
+
className={`${INPUT_CLASS} font-mono text-xs resize-y`}
|
|
466
|
+
/>
|
|
467
|
+
{error && (
|
|
468
|
+
<p className="mt-1 text-xs text-destructive" role="alert">
|
|
469
|
+
{error}
|
|
470
|
+
</p>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Lightweight array editor — supports homogenous arrays of strings or
|
|
478
|
+
* objects. For block fields we deliberately keep this simple: full
|
|
479
|
+
* nested-field editing belongs in the document-level FieldRenderer.
|
|
480
|
+
*/
|
|
481
|
+
function ArrayFieldRenderer({ label, value, onChange }: SimpleFieldProps) {
|
|
482
|
+
const items = Array.isArray(value) ? value : [];
|
|
483
|
+
const isObjectArray = items.length > 0 && typeof items[0] === 'object' && items[0] !== null;
|
|
484
|
+
|
|
485
|
+
if (isObjectArray) {
|
|
486
|
+
// For object arrays fall back to JSON — mirrors what most consumers
|
|
487
|
+
// expect when their block declares `type: 'array'` with no schema.
|
|
488
|
+
return <JsonFieldRenderer label={label} value={value} onChange={onChange} />;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const updateItem = (idx: number, next: string) => {
|
|
492
|
+
const copy = [...items];
|
|
493
|
+
copy[idx] = next;
|
|
494
|
+
onChange(copy);
|
|
495
|
+
};
|
|
496
|
+
const addItem = () => onChange([...items, '']);
|
|
497
|
+
const removeItem = (idx: number) => onChange(items.filter((_, i) => i !== idx));
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<div>
|
|
501
|
+
<label className={LABEL_CLASS}>{label}</label>
|
|
502
|
+
<div className="space-y-2">
|
|
503
|
+
{items.map((item, idx) => (
|
|
504
|
+
<div key={idx} className="flex gap-2">
|
|
505
|
+
<input
|
|
506
|
+
type="text"
|
|
507
|
+
value={typeof item === 'string' ? item : ''}
|
|
508
|
+
onChange={(e) => updateItem(idx, e.target.value)}
|
|
509
|
+
className={`${INPUT_CLASS} flex-1`}
|
|
510
|
+
/>
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => removeItem(idx)}
|
|
514
|
+
aria-label={`Remove item ${idx + 1}`}
|
|
515
|
+
className="px-2 py-2 text-xs text-destructive border border-input rounded-md hover:bg-destructive/10 transition-colors"
|
|
516
|
+
>
|
|
517
|
+
<Trash2 size={14} />
|
|
518
|
+
</button>
|
|
519
|
+
</div>
|
|
520
|
+
))}
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
onClick={addItem}
|
|
524
|
+
className="w-full px-3 py-2 text-xs font-medium bg-accent text-foreground border border-input rounded-md hover:bg-accent/80 transition-colors"
|
|
525
|
+
>
|
|
526
|
+
+ Add item
|
|
527
|
+
</button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
@@ -207,6 +207,22 @@ export function PageBuilder({
|
|
|
207
207
|
const canvasWidth = DEVICE_WIDTHS[deviceMode];
|
|
208
208
|
const isConstrained = deviceMode !== 'desktop';
|
|
209
209
|
|
|
210
|
+
// `pageBuilder.previewTheme` lets consumers preview blocks with their
|
|
211
|
+
// brand fonts/colors/radii without baking them into the admin's tokens.
|
|
212
|
+
// We scope the CSS variables to the canvas wrapper so they never leak
|
|
213
|
+
// into the rest of the admin chrome.
|
|
214
|
+
const previewTheme = config?.pageBuilder?.previewTheme ?? {};
|
|
215
|
+
const previewStyle: Record<string, string> = {};
|
|
216
|
+
if (previewTheme.fontFamily) previewStyle['--preview-font-family'] = previewTheme.fontFamily;
|
|
217
|
+
if (previewTheme.headingFontFamily) previewStyle['--preview-heading-font-family'] = previewTheme.headingFontFamily;
|
|
218
|
+
if (previewTheme.primaryColor) previewStyle['--preview-primary'] = previewTheme.primaryColor;
|
|
219
|
+
if (previewTheme.secondaryColor) previewStyle['--preview-secondary'] = previewTheme.secondaryColor;
|
|
220
|
+
if (previewTheme.borderRadius) previewStyle['--preview-radius'] = previewTheme.borderRadius;
|
|
221
|
+
const canvasShellStyle: React.CSSProperties = {
|
|
222
|
+
...previewStyle,
|
|
223
|
+
fontFamily: previewTheme.fontFamily ?? undefined,
|
|
224
|
+
};
|
|
225
|
+
|
|
210
226
|
return (
|
|
211
227
|
<ErrorBoundary>
|
|
212
228
|
<div className="h-full flex flex-col bg-background overflow-hidden">
|
|
@@ -237,7 +253,8 @@ export function PageBuilder({
|
|
|
237
253
|
style={{ width: canvasWidth }}
|
|
238
254
|
>
|
|
239
255
|
<div
|
|
240
|
-
className={`h-full bg-background ${isConstrained ? 'shadow-lg border-x border-border' : ''}`}
|
|
256
|
+
className={`h-full bg-background page-builder-canvas-shell ${isConstrained ? 'shadow-lg border-x border-border' : ''}`}
|
|
257
|
+
style={canvasShellStyle}
|
|
241
258
|
>
|
|
242
259
|
<BuilderCanvas
|
|
243
260
|
tree={tree}
|