@actuate-media/cms-admin 0.60.0 → 0.62.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 (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/lib/page-editor-service.test.js +1 -0
  3. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  4. package/dist/__tests__/views/editor-top-bar.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/editor-top-bar.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/editor-top-bar.render.test.js +46 -0
  7. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -0
  8. package/dist/__tests__/views/seo-settings.render.test.js +21 -0
  9. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  10. package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
  11. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  12. package/dist/views/page-editor/EditorTopBar.js +12 -2
  13. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  14. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  15. package/dist/views/page-editor/PageSectionEditor.js +4 -1
  16. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  17. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  18. package/dist/views/page-editor/sections/SectionRenderer.js +4 -0
  19. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  20. package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts +9 -0
  21. package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts.map +1 -0
  22. package/dist/views/page-editor/sections/WidgetEmbedSection.js +18 -0
  23. package/dist/views/page-editor/sections/WidgetEmbedSection.js.map +1 -0
  24. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  25. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  26. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  27. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  28. package/dist/views/settings/SeoSettingsTab.js +22 -1
  29. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  30. package/dist/views/settings/useSeoSettings.d.ts +8 -0
  31. package/dist/views/settings/useSeoSettings.d.ts.map +1 -1
  32. package/dist/views/settings/useSeoSettings.js +61 -1
  33. package/dist/views/settings/useSeoSettings.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/lib/page-editor-service.test.ts +1 -0
  36. package/src/__tests__/views/editor-top-bar.render.test.tsx +65 -0
  37. package/src/__tests__/views/seo-settings.render.test.tsx +31 -0
  38. package/src/views/page-editor/EditorTopBar.tsx +20 -1
  39. package/src/views/page-editor/PageSectionEditor.tsx +9 -0
  40. package/src/views/page-editor/sections/SectionRenderer.tsx +4 -0
  41. package/src/views/page-editor/sections/WidgetEmbedSection.tsx +32 -0
  42. package/src/views/post-editor/PostSectionEditor.tsx +9 -0
  43. package/src/views/settings/SeoSettingsTab.tsx +145 -0
  44. package/src/views/settings/useSeoSettings.ts +74 -1
@@ -114,6 +114,13 @@ export function PageSectionEditor({
114
114
  )
115
115
  }, [config])
116
116
 
117
+ const publishCheck = useMemo(
118
+ () => (page ? validatePage(page, true) : { errors: [''], warnings: [] }),
119
+ [page],
120
+ )
121
+ const publishReady = Boolean(page) && publishCheck.errors.length === 0
122
+ const publishDisabledReason = publishCheck.errors[0]
123
+
117
124
  // ─── Load ──────────────────────────────────────────────────────────
118
125
  useEffect(() => {
119
126
  let cancelled = false
@@ -463,6 +470,8 @@ export function PageSectionEditor({
463
470
  publishing={publishing}
464
471
  canEdit={effectiveCanEdit}
465
472
  canPublish={canPublish && effectiveCanEdit}
473
+ publishReady={publishReady}
474
+ publishDisabledReason={publishDisabledReason}
466
475
  viewport={viewport}
467
476
  onViewport={setViewport}
468
477
  onBack={() => guardedNavigate('/pages')}
@@ -17,6 +17,7 @@ import { AuthorBioSection } from './AuthorBioSection.js'
17
17
  import { RelatedPostsSection } from './RelatedPostsSection.js'
18
18
  import { GallerySection } from './GallerySection.js'
19
19
  import { EmbedSection } from './EmbedSection.js'
20
+ import { WidgetEmbedSection } from './WidgetEmbedSection.js'
20
21
 
21
22
  /**
22
23
  * Section types with a real canvas body component below. Anything else —
@@ -36,6 +37,7 @@ const CANVAS_RENDERED_TYPES: ReadonlySet<string> = new Set([
36
37
  'related-posts',
37
38
  'gallery',
38
39
  'embed',
40
+ 'widget-embed',
39
41
  ])
40
42
 
41
43
  /** True when the canvas renders this type for real (not as a placeholder). */
@@ -70,6 +72,8 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
70
72
  return <GallerySection section={section} />
71
73
  case 'embed':
72
74
  return <EmbedSection section={section} />
75
+ case 'widget-embed':
76
+ return <WidgetEmbedSection section={section} />
73
77
  default: {
74
78
  // Custom (config-declared) and unmanaged types: use a consumer-supplied
75
79
  // canvas renderer when one is registered (see admin-renderers.tsx).
@@ -0,0 +1,32 @@
1
+ import { Puzzle } from 'lucide-react'
2
+ import { parseWidgetEmbed, WIDGET_PROVIDER_LABEL } from '@actuate-media/cms-core/page-sections'
3
+ import type { PageSection } from '../../../lib/page-editor-service.js'
4
+ import { str } from './parts.js'
5
+
6
+ /**
7
+ * Canvas body for the `widget-embed` section. Shows a reserved-height placeholder
8
+ * instead of loading third-party scripts inside the editor.
9
+ */
10
+ export function WidgetEmbedSection({ section }: { section: PageSection }) {
11
+ const c = section.content
12
+ const parsed = parseWidgetEmbed(c)
13
+ const title = str(c, 'title')
14
+ const minHeight = parsed?.minHeightPx ?? 280
15
+
16
+ return (
17
+ <section className="mx-auto max-w-3xl px-6" aria-label={title || 'Third-party widget'}>
18
+ <div
19
+ className="bg-muted text-muted-foreground relative flex w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-xl"
20
+ style={{ minHeight: `${minHeight}px` }}
21
+ >
22
+ <Puzzle className="h-8 w-8" aria-hidden />
23
+ <p className="text-foreground text-sm font-medium">
24
+ {parsed
25
+ ? `${WIDGET_PROVIDER_LABEL[parsed.provider]} widget`
26
+ : 'Configure widget provider + ids'}
27
+ </p>
28
+ {title && <p className="text-muted-foreground px-4 text-center text-xs">{title}</p>}
29
+ </div>
30
+ </section>
31
+ )
32
+ }
@@ -151,6 +151,13 @@ export function PostSectionEditor({
151
151
  )
152
152
  }, [config, postType])
153
153
 
154
+ const publishCheck = useMemo(
155
+ () => (post ? validatePost(post, true) : { errors: [''], warnings: [] }),
156
+ [post],
157
+ )
158
+ const publishReady = Boolean(post) && publishCheck.errors.length === 0
159
+ const publishDisabledReason = publishCheck.errors[0]
160
+
154
161
  // ─── Load ──────────────────────────────────────────────────────────
155
162
  useEffect(() => {
156
163
  let cancelled = false
@@ -504,6 +511,8 @@ export function PostSectionEditor({
504
511
  publishing={publishing}
505
512
  canEdit={canEdit}
506
513
  canPublish={canPublish && canEdit}
514
+ publishReady={publishReady}
515
+ publishDisabledReason={publishDisabledReason}
507
516
  viewport={viewport}
508
517
  backLabel="Posts"
509
518
  onViewport={setViewport}
@@ -126,6 +126,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
126
126
  <SettingsValidationSummary issues={seo.issues} />
127
127
 
128
128
  <MetaDefaultsCard seo={seo} canEdit={canEdit} />
129
+ <OrganizationCard seo={seo} canEdit={canEdit} />
129
130
  <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
130
131
  <SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
131
132
 
@@ -409,6 +410,150 @@ function SeoPreviewCard({
409
410
  )
410
411
  }
411
412
 
413
+ // ---------------------------------------------------------------------------
414
+ // Organization (Schema.org)
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function OrganizationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
418
+ const nameId = useId()
419
+ const urlId = useId()
420
+ const sameAsId = useId()
421
+ const [pickerOpen, setPickerOpen] = useState(false)
422
+
423
+ const { form, setField, issues, sources, siteName, siteUrl } = seo
424
+ const nameIssue = issues.find((i) => i.field === 'organizationName')
425
+ const urlIssue = issues.find((i) => i.field === 'organizationUrl')
426
+ const logoIssue = issues.find((i) => i.field === 'organizationLogo')
427
+ const sameAsIssue = issues.find((i) => i.field === 'organizationSameAs')
428
+
429
+ const nameEditable = sources.organizationName?.editable !== false && canEdit
430
+ const urlEditable = sources.organizationUrl?.editable !== false && canEdit
431
+ const logoEditable = sources.organizationLogo?.editable !== false && canEdit
432
+ const sameAsEditable = sources.organizationSameAs?.editable !== false && canEdit
433
+
434
+ return (
435
+ <SettingsCard
436
+ title="Organization"
437
+ description="Schema.org Organization data injected sitewide in JSON-LD. Logo and social profiles should use absolute production URLs."
438
+ >
439
+ <div className="space-y-4">
440
+ <div>
441
+ <label htmlFor={nameId} className={LABEL_CLASS}>
442
+ Organization name
443
+ <SettingsSourceBadge meta={sources.organizationName} />
444
+ </label>
445
+ <input
446
+ id={nameId}
447
+ type="text"
448
+ value={form.organizationName}
449
+ disabled={!nameEditable}
450
+ placeholder={siteName || 'Your organization'}
451
+ onChange={(e) => setField('organizationName', e.target.value)}
452
+ className={INPUT_CLASS}
453
+ />
454
+ <p className="text-muted-foreground mt-1 text-sm">Falls back to site name when empty.</p>
455
+ {nameIssue && <p className="text-destructive mt-1 text-sm">{nameIssue.message}</p>}
456
+ </div>
457
+
458
+ <div>
459
+ <label htmlFor={urlId} className={LABEL_CLASS}>
460
+ Organization URL
461
+ <SettingsSourceBadge meta={sources.organizationUrl} />
462
+ </label>
463
+ <input
464
+ id={urlId}
465
+ type="url"
466
+ value={form.organizationUrl}
467
+ disabled={!urlEditable}
468
+ placeholder={siteUrl || 'https://example.com'}
469
+ aria-invalid={urlIssue?.severity === 'error'}
470
+ onChange={(e) => setField('organizationUrl', e.target.value)}
471
+ className={INPUT_CLASS}
472
+ />
473
+ <p className="text-muted-foreground mt-1 text-sm">
474
+ Canonical organization homepage. Falls back to site URL when empty.
475
+ </p>
476
+ {urlIssue && <p className="text-destructive mt-1 text-sm">{urlIssue.message}</p>}
477
+ </div>
478
+
479
+ <div>
480
+ <span className={LABEL_CLASS}>
481
+ Organization logo
482
+ <SettingsSourceBadge meta={sources.organizationLogo} />
483
+ </span>
484
+ <div className="flex items-center gap-3">
485
+ <div className="border-border bg-muted flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border">
486
+ {form.organizationLogo ? (
487
+ // eslint-disable-next-line @next/next/no-img-element
488
+ <img
489
+ src={form.organizationLogo}
490
+ alt="Organization logo preview"
491
+ className="h-full w-full object-contain p-1"
492
+ />
493
+ ) : (
494
+ <ImageIcon size={20} className="text-muted-foreground" aria-hidden="true" />
495
+ )}
496
+ </div>
497
+ <div className="flex flex-col gap-2">
498
+ <div className="flex items-center gap-2">
499
+ <button
500
+ type="button"
501
+ onClick={() => setPickerOpen(true)}
502
+ disabled={!logoEditable}
503
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
504
+ >
505
+ <Upload size={14} aria-hidden="true" />
506
+ {form.organizationLogo ? 'Replace logo' : 'Choose logo'}
507
+ </button>
508
+ {form.organizationLogo && logoEditable && (
509
+ <button
510
+ type="button"
511
+ onClick={() => setField('organizationLogo', '')}
512
+ className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors"
513
+ >
514
+ <X size={14} aria-hidden="true" />
515
+ Remove
516
+ </button>
517
+ )}
518
+ </div>
519
+ <p className="text-muted-foreground text-sm">Square PNG or SVG recommended.</p>
520
+ </div>
521
+ </div>
522
+ {logoIssue && <p className="text-destructive mt-1 text-sm">{logoIssue.message}</p>}
523
+ </div>
524
+
525
+ <div>
526
+ <label htmlFor={sameAsId} className={LABEL_CLASS}>
527
+ Social profiles (sameAs)
528
+ <SettingsSourceBadge meta={sources.organizationSameAs} />
529
+ </label>
530
+ <textarea
531
+ id={sameAsId}
532
+ rows={4}
533
+ value={form.organizationSameAs}
534
+ disabled={!sameAsEditable}
535
+ placeholder={'https://linkedin.com/company/your-brand\nhttps://twitter.com/your-brand'}
536
+ aria-invalid={sameAsIssue?.severity === 'error'}
537
+ onChange={(e) => setField('organizationSameAs', e.target.value)}
538
+ className={`${INPUT_CLASS} resize-y font-mono text-sm`}
539
+ />
540
+ <p className="text-muted-foreground mt-1 text-sm">
541
+ One HTTPS profile URL per line (LinkedIn, X, Facebook, YouTube, etc.).
542
+ </p>
543
+ {sameAsIssue && <p className="text-destructive mt-1 text-sm">{sameAsIssue.message}</p>}
544
+ </div>
545
+ </div>
546
+
547
+ <MediaPickerModal
548
+ open={pickerOpen}
549
+ onClose={() => setPickerOpen(false)}
550
+ accept="image/*"
551
+ onSelect={(url) => setField('organizationLogo', url)}
552
+ />
553
+ </SettingsCard>
554
+ )
555
+ }
556
+
412
557
  // ---------------------------------------------------------------------------
413
558
  // Search Engine Verification
414
559
  // ---------------------------------------------------------------------------
@@ -39,6 +39,14 @@ export interface SeoSettingsForm {
39
39
  autoGenerateSitemap: boolean
40
40
  /** Notify Google/Bing when the sitemap changes (`module`). */
41
41
  pingOnPublish: boolean
42
+ /** Schema.org Organization name (falls back to site name when empty). */
43
+ organizationName: string
44
+ /** Canonical organization URL (falls back to site URL when empty). */
45
+ organizationUrl: string
46
+ /** Absolute logo URL for Organization JSON-LD. */
47
+ organizationLogo: string
48
+ /** Social profile URLs, one per line (`sameAs`). */
49
+ organizationSameAs: string
42
50
  }
43
51
 
44
52
  export interface SitemapSummary {
@@ -61,6 +69,10 @@ const EMPTY_FORM: SeoSettingsForm = {
61
69
  facebookDomainVerification: '',
62
70
  autoGenerateSitemap: true,
63
71
  pingOnPublish: true,
72
+ organizationName: '',
73
+ organizationUrl: '',
74
+ organizationLogo: '',
75
+ organizationSameAs: '',
64
76
  }
65
77
 
66
78
  /** Recommended max length for the default meta description (SERP truncation). */
@@ -93,6 +105,31 @@ export function validateTitleTemplateClient(template: string): {
93
105
  return { valid: true, unknownTokens: [] }
94
106
  }
95
107
 
108
+ function parseSameAsLines(raw: string): string[] {
109
+ return raw
110
+ .split('\n')
111
+ .map((line) => line.trim())
112
+ .filter(Boolean)
113
+ }
114
+
115
+ function validateUrlField(
116
+ value: string,
117
+ field: keyof SeoSettingsForm,
118
+ label: string,
119
+ ): ValidationIssue[] {
120
+ const trimmed = value.trim()
121
+ if (!trimmed) return []
122
+ try {
123
+ const u = new URL(trimmed)
124
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
125
+ return [{ field, message: `${label} must use http:// or https://`, severity: 'error' }]
126
+ }
127
+ } catch {
128
+ return [{ field, message: `${label} is not a valid URL.`, severity: 'error' }]
129
+ }
130
+ return []
131
+ }
132
+
96
133
  function validate(form: SeoSettingsForm): ValidationIssue[] {
97
134
  const issues: ValidationIssue[] = []
98
135
  const tpl = validateTitleTemplateClient(form.titleTemplate)
@@ -122,6 +159,11 @@ function validate(form: SeoSettingsForm): ValidationIssue[] {
122
159
  severity: 'warning',
123
160
  })
124
161
  }
162
+ issues.push(...validateUrlField(form.organizationUrl, 'organizationUrl', 'Organization URL'))
163
+ issues.push(...validateUrlField(form.organizationLogo, 'organizationLogo', 'Organization logo'))
164
+ for (const url of parseSameAsLines(form.organizationSameAs)) {
165
+ issues.push(...validateUrlField(url, 'organizationSameAs', 'Social profile URL'))
166
+ }
125
167
  return issues
126
168
  }
127
169
 
@@ -192,6 +234,7 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
192
234
  const staticSite = seo.static?.site ?? {}
193
235
  const overSite = seo.overrides?.site ?? {}
194
236
  const verification = effectiveSite.verification ?? {}
237
+ const organization = effectiveSite.organization ?? {}
195
238
  const crawl = crawlRes.data ?? {}
196
239
 
197
240
  const loaded: SeoSettingsForm = {
@@ -206,6 +249,14 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
206
249
  facebookDomainVerification: String(verification.facebookDomain ?? ''),
207
250
  autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
208
251
  pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
252
+ organizationName: String(organization.name ?? ''),
253
+ organizationUrl: String(organization.url ?? ''),
254
+ organizationLogo: String(organization.logo ?? ''),
255
+ organizationSameAs: Array.isArray(organization.sameAs)
256
+ ? (organization.sameAs as unknown[])
257
+ .filter((u): u is string => typeof u === 'string')
258
+ .join('\n')
259
+ : '',
209
260
  }
210
261
 
211
262
  // `editable` reflects the field's MANAGEMENT source (config vs database),
@@ -216,11 +267,19 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
216
267
  staticSite[key]
217
268
  ? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
218
269
  : { source: 'database', editable: true }
270
+ const orgConfigManaged = (): SettingsSourceMetadata =>
271
+ staticSite.organization
272
+ ? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
273
+ : { source: 'database', editable: true }
219
274
 
220
275
  setSources({
221
276
  titleTemplate: configManaged('titleTemplate'),
222
277
  defaultMetaDescription: configManaged('defaultMetaDescription'),
223
278
  defaultOgImage: configManaged('defaultOgImage'),
279
+ organizationName: orgConfigManaged(),
280
+ organizationUrl: orgConfigManaged(),
281
+ organizationLogo: orgConfigManaged(),
282
+ organizationSameAs: orgConfigManaged(),
224
283
  verification: { source: 'database', editable: true },
225
284
  sitemap: { source: 'database', editable: true },
226
285
  })
@@ -279,7 +338,11 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
279
338
  form.bingVerification !== baseline.bingVerification ||
280
339
  form.yandexVerification !== baseline.yandexVerification ||
281
340
  form.pinterestVerification !== baseline.pinterestVerification ||
282
- form.facebookDomainVerification !== baseline.facebookDomainVerification
341
+ form.facebookDomainVerification !== baseline.facebookDomainVerification ||
342
+ form.organizationName !== baseline.organizationName ||
343
+ form.organizationUrl !== baseline.organizationUrl ||
344
+ form.organizationLogo !== baseline.organizationLogo ||
345
+ form.organizationSameAs !== baseline.organizationSameAs
283
346
  const sitemapChanged =
284
347
  form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
285
348
  form.pingOnPublish !== baseline.pingOnPublish
@@ -302,6 +365,16 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
302
365
  pinterest: form.pinterestVerification.trim() || undefined,
303
366
  facebookDomain: form.facebookDomainVerification.trim() || undefined,
304
367
  },
368
+ organization: {
369
+ ...(overrideSite.organization ?? {}),
370
+ name: form.organizationName.trim() || undefined,
371
+ url: form.organizationUrl.trim() || undefined,
372
+ logo: form.organizationLogo.trim() || undefined,
373
+ sameAs: (() => {
374
+ const urls = parseSameAsLines(form.organizationSameAs)
375
+ return urls.length > 0 ? urls : undefined
376
+ })(),
377
+ },
305
378
  }
306
379
  const res = await cmsApi('/seo/config', {
307
380
  method: 'PUT',