@actuate-media/cms-admin 0.7.2 → 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/components/SEOPanel.d.ts +1 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +44 -2
- package/dist/components/SEOPanel.js.map +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/DocumentEdit.js +1 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -2
- package/dist/views/Settings.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 +3 -2
- package/src/AdminRoot.tsx +94 -41
- package/src/components/SEOPanel.tsx +83 -12
- package/src/lib/api.ts +31 -3
- package/src/views/DocumentEdit.tsx +1 -1
- package/src/views/Settings.tsx +39 -4
- package/src/views/page-builder/BlockEditor.tsx +209 -30
- package/src/views/page-builder/PageBuilder.tsx +18 -1
|
@@ -85,7 +85,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
85
85
|
|
|
86
86
|
const SEO_FIELDS: (keyof SEOData)[] = [
|
|
87
87
|
'metaTitle', 'metaDescription', 'focusKeyphrase', 'canonical',
|
|
88
|
-
'noIndex', 'noFollow', 'ogTitle', 'ogDescription', 'ogImage',
|
|
88
|
+
'robotsPolicy', 'noIndex', 'noFollow', 'ogTitle', 'ogDescription', 'ogImage',
|
|
89
89
|
'twitterTitle', 'twitterDescription', 'twitterImage',
|
|
90
90
|
'isCornerstone', 'schemaType',
|
|
91
91
|
];
|
package/src/views/Settings.tsx
CHANGED
|
@@ -22,6 +22,9 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
22
22
|
const [siteUrl, setSiteUrl] = useState('https://example.com');
|
|
23
23
|
const [language, setLanguage] = useState('en');
|
|
24
24
|
const [timezone, setTimezone] = useState('UTC');
|
|
25
|
+
const [defaultNoIndex, setDefaultNoIndex] = useState(false);
|
|
26
|
+
const [defaultNoFollow, setDefaultNoFollow] = useState(false);
|
|
27
|
+
const [noIndexNonProduction, setNoIndexNonProduction] = useState(false);
|
|
25
28
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
|
26
29
|
const [sessionTimeout, setSessionTimeout] = useState(false);
|
|
27
30
|
const [ipWhitelist, setIpWhitelist] = useState(false);
|
|
@@ -61,6 +64,9 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
61
64
|
setSiteUrl(data.siteUrl ?? '');
|
|
62
65
|
setLanguage(data.language ?? 'en');
|
|
63
66
|
setTimezone(data.timezone ?? 'UTC');
|
|
67
|
+
setDefaultNoIndex(data.defaultNoIndex ?? false);
|
|
68
|
+
setDefaultNoFollow(data.defaultNoFollow ?? false);
|
|
69
|
+
setNoIndexNonProduction(data.noIndexNonProduction ?? false);
|
|
64
70
|
setTwoFactorEnabled(data.twoFactorEnabled ?? false);
|
|
65
71
|
setSessionTimeout(data.sessionTimeout ?? false);
|
|
66
72
|
setIpWhitelist(data.ipWhitelist ?? false);
|
|
@@ -87,6 +93,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
87
93
|
method: 'PUT',
|
|
88
94
|
body: JSON.stringify({
|
|
89
95
|
siteTitle, tagline, siteUrl, language, timezone,
|
|
96
|
+
defaultNoIndex, defaultNoFollow, noIndexNonProduction,
|
|
90
97
|
twoFactorEnabled, sessionTimeout, ipWhitelist,
|
|
91
98
|
aiProvider, aiAltTags, aiMediaCategorize, aiMetaDescriptions,
|
|
92
99
|
aiReadability, aiSchema, aiBrandVoice, aiWritingAssistant,
|
|
@@ -197,6 +204,32 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
197
204
|
</div>
|
|
198
205
|
</div>
|
|
199
206
|
</div>
|
|
207
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
208
|
+
<h3 className="mb-1 text-sm font-medium text-foreground">SEO & Robots Defaults</h3>
|
|
209
|
+
<p className="mb-4 text-xs text-muted-foreground">
|
|
210
|
+
Set the site-wide default for search engine indexing. Individual pages can inherit or override these rules in their SEO panel.
|
|
211
|
+
</p>
|
|
212
|
+
<div className="space-y-4">
|
|
213
|
+
<ToggleSetting
|
|
214
|
+
label="Default No Index"
|
|
215
|
+
description="Ask search engines not to index pages unless a page explicitly allows indexing"
|
|
216
|
+
checked={defaultNoIndex}
|
|
217
|
+
onChange={setDefaultNoIndex}
|
|
218
|
+
/>
|
|
219
|
+
<ToggleSetting
|
|
220
|
+
label="Default No Follow"
|
|
221
|
+
description="Ask search engines not to follow page links unless a page explicitly allows following"
|
|
222
|
+
checked={defaultNoFollow}
|
|
223
|
+
onChange={setDefaultNoFollow}
|
|
224
|
+
/>
|
|
225
|
+
<ToggleSetting
|
|
226
|
+
label="Noindex Non-Production Environments"
|
|
227
|
+
description="Force noindex when the deployed environment is not production"
|
|
228
|
+
checked={noIndexNonProduction}
|
|
229
|
+
onChange={setNoIndexNonProduction}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
200
233
|
</Tabs.Content>
|
|
201
234
|
|
|
202
235
|
<Tabs.Content value="appearance" className="space-y-4">
|
|
@@ -513,14 +546,16 @@ function ToggleSetting({
|
|
|
513
546
|
return (
|
|
514
547
|
<div className="flex items-center justify-between gap-4">
|
|
515
548
|
<div className="flex-1">
|
|
516
|
-
<label className="text-sm font-medium text-
|
|
517
|
-
<p className="mt-0.5 text-xs text-
|
|
549
|
+
<label className="text-sm font-medium text-foreground">{label}</label>
|
|
550
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
|
518
551
|
</div>
|
|
519
552
|
<button
|
|
520
553
|
type="button"
|
|
554
|
+
role="switch"
|
|
555
|
+
aria-checked={checked}
|
|
556
|
+
aria-label={label}
|
|
521
557
|
onClick={() => onChange(!checked)}
|
|
522
|
-
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-
|
|
523
|
-
aria-pressed={checked}
|
|
558
|
+
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary ${checked ? 'bg-primary' : 'bg-muted'}`}
|
|
524
559
|
>
|
|
525
560
|
<span
|
|
526
561
|
className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
|
|
@@ -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}
|