@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.
@@ -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
  ];
@@ -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-gray-700">{label}</label>
517
- <p className="mt-0.5 text-xs text-gray-500">{description}</p>
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-blue-600' : 'bg-gray-300'}`}
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
- <textarea
184
- value={(value as string) ?? ''}
185
- onChange={(e) => onChange(e.target.value)}
186
- placeholder={definition.admin?.placeholder}
187
- rows={4}
188
- className={`${INPUT_CLASS} resize-y`}
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
- <div className="px-3 py-2 text-sm bg-muted border border-input rounded-md text-muted-foreground">
284
- {Array.isArray(value) ? `${value.length} items` : '0 items'}
285
- </div>
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}